Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:58:45 +08:00
commit 8e8f4ef631
9 changed files with 2752 additions and 0 deletions

View 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.