Initial commit
This commit is contained in:
625
skills/building-nextjs-apps/SKILL.md
Normal file
625
skills/building-nextjs-apps/SKILL.md
Normal file
@@ -0,0 +1,625 @@
|
||||
---
|
||||
name: building-nextjs-apps
|
||||
description: Specialized skill for building Next.js 15 App Router applications with React Server Components, Server Actions, and production-ready patterns. Use when implementing Next.js features, components, or application structure.
|
||||
---
|
||||
|
||||
# Building Next.js Apps
|
||||
|
||||
You are an expert in building production-ready Next.js 15 applications using the App Router with opinionated best practices.
|
||||
|
||||
## Enforced Patterns
|
||||
|
||||
### App Router Only
|
||||
- NEVER use Pages Router
|
||||
- Use App Router features: layouts, loading, error, not-found
|
||||
- Leverage nested layouts for shared UI
|
||||
- Use route groups for organization (no URL impact)
|
||||
|
||||
### Server Components First
|
||||
Default to Server Components. Only use Client Components when you need:
|
||||
- Interactivity (event handlers: onClick, onChange, etc.)
|
||||
- Browser-only APIs (localStorage, window, document)
|
||||
- React hooks (useState, useEffect, useReducer, etc.)
|
||||
- Third-party libraries that require client-side rendering
|
||||
|
||||
### Data Fetching
|
||||
|
||||
**Server Components** (Preferred):
|
||||
```typescript
|
||||
// app/posts/page.tsx
|
||||
import { getPosts } from '@/lib/data';
|
||||
|
||||
export default async function PostsPage() {
|
||||
const posts = await getPosts(); // Direct async call
|
||||
|
||||
return (
|
||||
<div>
|
||||
{posts.map(post => (
|
||||
<article key={post.id}>
|
||||
<h2>{post.title}</h2>
|
||||
<p>{post.content}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Client Components** (When needed):
|
||||
```typescript
|
||||
// components/posts-list.tsx
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function PostsList() {
|
||||
const [posts, setPosts] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/posts')
|
||||
.then(res => res.json())
|
||||
.then(setPosts);
|
||||
}, []);
|
||||
|
||||
return <div>{/* render posts */}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Mutations with Server Actions
|
||||
|
||||
**Form Actions** (Preferred):
|
||||
```typescript
|
||||
// app/actions.ts
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { z } from 'zod';
|
||||
|
||||
const CreatePostSchema = z.object({
|
||||
title: z.string().min(1, 'Title required'),
|
||||
content: z.string().min(1, 'Content required'),
|
||||
});
|
||||
|
||||
export async function createPost(formData: FormData) {
|
||||
const validated = CreatePostSchema.parse({
|
||||
title: formData.get('title'),
|
||||
content: formData.get('content'),
|
||||
});
|
||||
|
||||
// Write to database
|
||||
const postId = await db.createPost(validated);
|
||||
|
||||
revalidatePath('/posts');
|
||||
redirect(`/posts/${postId}`);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// app/posts/new/page.tsx
|
||||
import { createPost } from '@/app/actions';
|
||||
|
||||
export default function NewPostPage() {
|
||||
return (
|
||||
<form action={createPost}>
|
||||
<input name="title" required />
|
||||
<textarea name="content" required />
|
||||
<button type="submit">Create Post</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Programmatic Actions**:
|
||||
```typescript
|
||||
// components/delete-button.tsx
|
||||
'use client';
|
||||
|
||||
import { deletePost } from '@/app/actions';
|
||||
|
||||
export function DeleteButton({ postId }: { postId: string }) {
|
||||
return (
|
||||
<button onClick={() => deletePost(postId)}>
|
||||
Delete
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Route Handlers
|
||||
|
||||
Use for external API integrations, webhooks, or when Server Actions don't fit:
|
||||
|
||||
```typescript
|
||||
// app/api/webhook/route.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const headersList = headers();
|
||||
const signature = headersList.get('x-webhook-signature');
|
||||
|
||||
// Verify signature
|
||||
if (!verifySignature(signature)) {
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Process webhook
|
||||
await processWebhook(body);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
```
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
app/
|
||||
├── (auth)/ # Route group (no /auth in URL)
|
||||
│ ├── login/
|
||||
│ │ └── page.tsx
|
||||
│ ├── register/
|
||||
│ │ └── page.tsx
|
||||
│ └── layout.tsx # Shared auth layout
|
||||
├── (dashboard)/ # Another route group
|
||||
│ ├── posts/
|
||||
│ │ ├── [id]/
|
||||
│ │ │ ├── page.tsx # /posts/[id]
|
||||
│ │ │ └── edit/
|
||||
│ │ │ └── page.tsx # /posts/[id]/edit
|
||||
│ │ ├── new/
|
||||
│ │ │ └── page.tsx # /posts/new
|
||||
│ │ ├── page.tsx # /posts
|
||||
│ │ ├── loading.tsx # Loading UI
|
||||
│ │ └── error.tsx # Error boundary
|
||||
│ ├── settings/
|
||||
│ │ └── page.tsx
|
||||
│ └── layout.tsx # Dashboard layout with nav
|
||||
├── api/
|
||||
│ ├── webhook/
|
||||
│ │ └── route.ts
|
||||
│ └── health/
|
||||
│ └── route.ts
|
||||
├── actions.ts # Server Actions
|
||||
├── layout.tsx # Root layout
|
||||
├── page.tsx # Home page
|
||||
├── loading.tsx # Global loading
|
||||
├── error.tsx # Global error
|
||||
├── not-found.tsx # 404 page
|
||||
└── global.css # Tailwind imports
|
||||
|
||||
components/
|
||||
├── ui/ # Reusable UI components
|
||||
│ ├── button.tsx
|
||||
│ ├── card.tsx
|
||||
│ └── input.tsx
|
||||
└── features/ # Feature-specific components
|
||||
├── post-card.tsx
|
||||
└── post-form.tsx
|
||||
|
||||
lib/
|
||||
├── db/ # Database access
|
||||
│ ├── dynamodb.ts
|
||||
│ └── queries.ts
|
||||
├── auth/ # Auth utilities
|
||||
│ └── config.ts
|
||||
└── utils.ts # Shared utilities
|
||||
```
|
||||
|
||||
### Layouts
|
||||
|
||||
**Root Layout** (Required):
|
||||
```typescript
|
||||
// app/layout.tsx
|
||||
import './global.css';
|
||||
import { Inter } from 'next/font/google';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata = {
|
||||
title: 'My App',
|
||||
description: 'App description',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Nested Layouts**:
|
||||
```typescript
|
||||
// app/(dashboard)/layout.tsx
|
||||
import { Navigation } from '@/components/navigation';
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Navigation />
|
||||
<main className="flex-1 overflow-y-auto p-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Loading States
|
||||
|
||||
**Streaming with Suspense**:
|
||||
```typescript
|
||||
// app/dashboard/page.tsx
|
||||
import { Suspense } from 'react';
|
||||
import { PostsList } from '@/components/posts-list';
|
||||
import { StatsSkeleton } from '@/components/skeletons';
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<Suspense fallback={<StatsSkeleton />}>
|
||||
<PostsList />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Loading.tsx**:
|
||||
```typescript
|
||||
// app/dashboard/loading.tsx
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-gray-900" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
**Error Boundaries**:
|
||||
```typescript
|
||||
// app/dashboard/error.tsx
|
||||
'use client';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<h2 className="text-2xl font-bold mb-4">Something went wrong!</h2>
|
||||
<p className="text-gray-600 mb-4">{error.message}</p>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Not Found**:
|
||||
```typescript
|
||||
// app/posts/[id]/not-found.tsx
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div>
|
||||
<h2>Post Not Found</h2>
|
||||
<p>Could not find the requested post.</p>
|
||||
<Link href="/posts">View all posts</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Metadata
|
||||
|
||||
**Static Metadata**:
|
||||
```typescript
|
||||
// app/posts/page.tsx
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Posts',
|
||||
description: 'Browse all posts',
|
||||
};
|
||||
|
||||
export default function PostsPage() {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Dynamic Metadata**:
|
||||
```typescript
|
||||
// app/posts/[id]/page.tsx
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}): Promise<Metadata> {
|
||||
const post = await getPost(params.id);
|
||||
|
||||
if (!post) {
|
||||
return {
|
||||
title: 'Post Not Found',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
images: [post.coverImage],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PostPage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) {
|
||||
const post = await getPost(params.id);
|
||||
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <article>{/* render post */}</article>;
|
||||
}
|
||||
```
|
||||
|
||||
### Caching and Revalidation
|
||||
|
||||
**Revalidate Paths**:
|
||||
```typescript
|
||||
// app/actions.ts
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
export async function createPost(data: FormData) {
|
||||
await db.createPost(/* ... */);
|
||||
|
||||
revalidatePath('/posts'); // Revalidate specific path
|
||||
revalidatePath('/posts/[id]', 'page'); // Revalidate dynamic route
|
||||
revalidatePath('/', 'layout'); // Revalidate layout (all nested pages)
|
||||
}
|
||||
```
|
||||
|
||||
**Revalidate Tags**:
|
||||
```typescript
|
||||
// Fetch with tag
|
||||
export async function getPosts() {
|
||||
const res = await fetch('https://api.example.com/posts', {
|
||||
next: { tags: ['posts'] },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Revalidate by tag
|
||||
import { revalidateTag } from 'next/cache';
|
||||
|
||||
export async function createPost(data: FormData) {
|
||||
await db.createPost(/* ... */);
|
||||
|
||||
revalidateTag('posts'); // Revalidates all fetches with 'posts' tag
|
||||
}
|
||||
```
|
||||
|
||||
**Time-based Revalidation**:
|
||||
```typescript
|
||||
// Revalidate every hour
|
||||
export async function getPosts() {
|
||||
const res = await fetch('https://api.example.com/posts', {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication with NextAuth.js
|
||||
|
||||
**Configuration**:
|
||||
```typescript
|
||||
// lib/auth/config.ts
|
||||
import NextAuth from 'next-auth';
|
||||
import Google from 'next-auth/providers/google';
|
||||
import { DynamoDBAdapter } from '@auth/dynamodb-adapter';
|
||||
import { DynamoDB } from '@aws-sdk/client-dynamodb';
|
||||
import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb';
|
||||
|
||||
const client = DynamoDBDocument.from(new DynamoDB({}), {
|
||||
marshallOptions: {
|
||||
convertEmptyValues: true,
|
||||
removeUndefinedValues: true,
|
||||
convertClassInstanceToMap: true,
|
||||
},
|
||||
});
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
adapter: DynamoDBAdapter(client),
|
||||
providers: [
|
||||
Google({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: 'jwt',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**API Route**:
|
||||
```typescript
|
||||
// app/api/auth/[...nextauth]/route.ts
|
||||
import { handlers } from '@/lib/auth/config';
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
```
|
||||
|
||||
**Middleware** (Protect routes):
|
||||
```typescript
|
||||
// middleware.ts
|
||||
import { auth } from '@/lib/auth/config';
|
||||
|
||||
export default auth((req) => {
|
||||
if (!req.auth && req.nextUrl.pathname.startsWith('/dashboard')) {
|
||||
return Response.redirect(new URL('/login', req.url));
|
||||
}
|
||||
});
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
||||
};
|
||||
```
|
||||
|
||||
**Get Session** (Server Component):
|
||||
```typescript
|
||||
import { auth } from '@/lib/auth/config';
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
return <div>Welcome, {session.user.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
**Get Session** (Client Component):
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
export function UserProfile() {
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
if (status === 'loading') {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return <div>Not signed in</div>;
|
||||
}
|
||||
|
||||
return <div>Signed in as {session?.user?.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
**Validation**:
|
||||
```typescript
|
||||
// lib/env.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']),
|
||||
DYNAMODB_TABLE_NAME: z.string().min(1),
|
||||
AWS_REGION: z.string().min(1),
|
||||
GOOGLE_CLIENT_ID: z.string().min(1),
|
||||
GOOGLE_CLIENT_SECRET: z.string().min(1),
|
||||
NEXTAUTH_URL: z.string().url(),
|
||||
NEXTAUTH_SECRET: z.string().min(32),
|
||||
});
|
||||
|
||||
export const env = envSchema.parse(process.env);
|
||||
```
|
||||
|
||||
**.env.example**:
|
||||
```bash
|
||||
# Database
|
||||
DYNAMODB_TABLE_NAME=my-app-table
|
||||
AWS_REGION=us-east-1
|
||||
AWS_ACCESS_KEY_ID=your-access-key
|
||||
AWS_SECRET_ACCESS_KEY=your-secret-key
|
||||
|
||||
# Auth
|
||||
GOOGLE_CLIENT_ID=your-google-client-id
|
||||
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_SECRET=your-nextauth-secret-min-32-chars
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
**Unit Tests** (Vitest):
|
||||
```typescript
|
||||
// lib/utils.test.ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatDate } from './utils';
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('formats date correctly', () => {
|
||||
const date = new Date('2024-01-01');
|
||||
expect(formatDate(date)).toBe('January 1, 2024');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**E2E Tests** (Playwright):
|
||||
```typescript
|
||||
// tests/e2e/posts.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('create new post', async ({ page }) => {
|
||||
await page.goto('/posts/new');
|
||||
|
||||
await page.fill('input[name="title"]', 'Test Post');
|
||||
await page.fill('textarea[name="content"]', 'Test content');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
await expect(page).toHaveURL(/\/posts\/\w+/);
|
||||
await expect(page.locator('h1')).toContainText('Test Post');
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
1. **Server Components by default** - Use 'use client' sparingly
|
||||
2. **Server Actions for mutations** - Forms and programmatic actions
|
||||
3. **Async Server Components** - Fetch data directly in components
|
||||
4. **Nested layouts** - Share UI across routes
|
||||
5. **Loading and error states** - Use loading.tsx, error.tsx, Suspense
|
||||
6. **Metadata API** - Static and dynamic SEO
|
||||
7. **Route groups** - Organize without affecting URLs
|
||||
8. **Streaming** - Progressive rendering with Suspense
|
||||
9. **Revalidation** - Keep data fresh with revalidatePath/revalidateTag
|
||||
10. **Type-safe environment variables** - Validate with Zod
|
||||
|
||||
You build with these patterns every time. No exceptions.
|
||||
Reference in New Issue
Block a user