AgentSkillsCN

Nextjs Best Practices

Next.js 最佳实践

SKILL.md

Next.js Best Practices Skill

Next.js 14+ App Router patterns for production applications

PRINCIPLES

  1. Server-first: Default to Server Components
  2. Streaming: Use Suspense for progressive rendering
  3. Colocation: Keep related files together
  4. Type-safe: Full TypeScript support

APP ROUTER ARCHITECTURE

File Conventions

code
app/
├── layout.tsx          # Root layout (required)
├── page.tsx            # Home page
├── loading.tsx         # Loading UI
├── error.tsx           # Error boundary
├── not-found.tsx       # 404 page
├── global-error.tsx    # Global error boundary
├── (marketing)/        # Route group (no URL impact)
│   ├── about/page.tsx
│   └── contact/page.tsx
├── dashboard/
│   ├── layout.tsx      # Nested layout
│   ├── page.tsx
│   └── settings/
│       └── page.tsx
└── api/
    └── users/
        └── route.ts    # API route

Route Groups

typescript
// (auth) - groups auth-related routes without affecting URL
// app/(auth)/login/page.tsx -> /login
// app/(auth)/register/page.tsx -> /register

// Use for:
// 1. Organizing routes by feature
// 2. Different layouts for route sections
// 3. Parallel routes

Dynamic Routes

typescript
// app/blog/[slug]/page.tsx
interface Props {
  params: { slug: string };
  searchParams: { [key: string]: string | string[] | undefined };
}

export default function BlogPost({ params, searchParams }: Props) {
  return <article>Post: {params.slug}</article>;
}

// Generate static params for SSG
export async function generateStaticParams() {
  const posts = await getPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

SERVER COMPONENTS

Default Behavior

typescript
// Server Component (default - no directive needed)
async function ProductList() {
  // Direct database access - no API needed
  const products = await db.product.findMany();
  
  // Sensitive operations safe on server
  const apiKey = process.env.STRIPE_SECRET_KEY;
  
  return (
    <ul>
      {products.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  );
}

Data Fetching Patterns

typescript
// Parallel data fetching (recommended)
async function Dashboard() {
  // Initiates both requests simultaneously
  const [user, posts] = await Promise.all([
    getUser(),
    getPosts()
  ]);
  
  return (
    <div>
      <UserProfile user={user} />
      <PostList posts={posts} />
    </div>
  );
}

// Sequential (when data depends on previous)
async function UserPosts({ userId }: { userId: string }) {
  const user = await getUser(userId);
  const posts = await getPosts(user.id); // Needs user.id
  
  return <PostList posts={posts} />;
}

Streaming with Suspense

typescript
// app/dashboard/page.tsx
import { Suspense } from 'react';

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      
      {/* Instant - no data fetching */}
      <WelcomeMessage />
      
      {/* Streams when ready */}
      <Suspense fallback={<ChartSkeleton />}>
        <Analytics />
      </Suspense>
      
      <Suspense fallback={<TableSkeleton />}>
        <RecentOrders />
      </Suspense>
    </div>
  );
}

CLIENT COMPONENTS

When to Use

typescript
// Use 'use client' for:
// ✅ Interactivity (onClick, onChange)
// ✅ State (useState, useReducer)
// ✅ Effects (useEffect)
// ✅ Browser APIs (localStorage, window)
// ✅ Custom hooks with state

'use client';

import { useState, useTransition } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);
  const [isPending, startTransition] = useTransition();
  
  const handleClick = () => {
    startTransition(() => {
      setCount(c => c + 1);
    });
  };
  
  return (
    <button onClick={handleClick} disabled={isPending}>
      Count: {count}
    </button>
  );
}

Composition Pattern

typescript
// Server Component wraps Client Component
// app/products/page.tsx (Server)
import { ProductFilters } from './filters'; // Client

async function ProductsPage() {
  const products = await getProducts();
  
  return (
    <div>
      {/* Client Component for interactivity */}
      <ProductFilters />
      
      {/* Server-rendered list */}
      <ProductList products={products} />
    </div>
  );
}

// Pass Server Component as children
export function ClientWrapper({ children }: { children: React.ReactNode }) {
  return <div className="interactive">{children}</div>;
}

SERVER ACTIONS

Form Handling

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).max(100),
  content: z.string().min(10),
});

export async function createPost(formData: FormData) {
  // Validate
  const validated = CreatePostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
  });
  
  if (!validated.success) {
    return { error: validated.error.flatten().fieldErrors };
  }
  
  // Create
  await db.post.create({ data: validated.data });
  
  // Revalidate and redirect
  revalidatePath('/posts');
  redirect('/posts');
}

// Usage in component
export function CreatePostForm() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <textarea name="content" required />
      <button type="submit">Create</button>
    </form>
  );
}

With useFormState

typescript
'use client';

import { useFormState, useFormStatus } from 'react-dom';
import { createPost } from './actions';

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Creating...' : 'Create Post'}
    </button>
  );
}

export function CreatePostForm() {
  const [state, formAction] = useFormState(createPost, null);
  
  return (
    <form action={formAction}>
      <input name="title" />
      {state?.error?.title && <p>{state.error.title}</p>}
      
      <textarea name="content" />
      {state?.error?.content && <p>{state.error.content}</p>}
      
      <SubmitButton />
    </form>
  );
}

METADATA & SEO

Static Metadata

typescript
// app/layout.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: {
    default: 'My App',
    template: '%s | My App', // "About | My App"
  },
  description: 'My amazing application',
  keywords: ['Next.js', 'React', 'TypeScript'],
  authors: [{ name: 'Author' }],
  openGraph: {
    title: 'My App',
    description: 'My amazing application',
    url: 'https://myapp.com',
    siteName: 'My App',
    images: [{ url: '/og-image.png', width: 1200, height: 630 }],
    locale: 'en_US',
    type: 'website',
  },
  twitter: {
    card: 'summary_large_image',
    title: 'My App',
    description: 'My amazing application',
    images: ['/twitter-image.png'],
  },
  robots: {
    index: true,
    follow: true,
  },
};

Dynamic Metadata

typescript
// app/blog/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next';

interface Props {
  params: { slug: string };
}

export async function generateMetadata(
  { params }: Props,
  parent: ResolvingMetadata
): Promise<Metadata> {
  const post = await getPost(params.slug);
  const previousImages = (await parent).openGraph?.images || [];
  
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      images: [post.image, ...previousImages],
    },
  };
}

CACHING & REVALIDATION

Fetch Caching

typescript
// Force cache (default for GET)
const data = await fetch('https://api.example.com/data');

// No cache
const data = await fetch('https://api.example.com/data', {
  cache: 'no-store',
});

// Time-based revalidation
const data = await fetch('https://api.example.com/data', {
  next: { revalidate: 3600 }, // Revalidate every hour
});

// Tag-based revalidation
const data = await fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] },
});

// Revalidate by tag
import { revalidateTag } from 'next/cache';
revalidateTag('posts');

// Revalidate by path
import { revalidatePath } from 'next/cache';
revalidatePath('/posts');

ANTI-PATTERNS

❌ Avoid

typescript
// ❌ Using 'use client' for everything
'use client';
export function StaticContent() {
  return <div>This doesn't need client JS</div>;
}

// ❌ Fetching in useEffect for initial data
'use client';
export function Products() {
  const [products, setProducts] = useState([]);
  useEffect(() => {
    fetch('/api/products').then(res => res.json()).then(setProducts);
  }, []);
  return <ProductList products={products} />;
}

// ❌ Passing server-only code to client
'use client';
import { db } from './db'; // ❌ Database in client!

// ❌ Prop drilling instead of composition
function Parent() {
  const data = await getData();
  return <Child data={data} />; // Deep prop drilling
}

✅ Prefer

typescript
// ✅ Server Component by default
export function StaticContent() {
  return <div>No client JS needed</div>;
}

// ✅ Fetch in Server Component
async function Products() {
  const products = await getProducts(); // Direct fetch
  return <ProductList products={products} />;
}

// ✅ Composition pattern
async function Parent() {
  const data = await getData();
  return (
    <ClientWrapper>
      <ServerContent data={data} />
    </ClientWrapper>
  );
}

QUICK REFERENCE

FeatureWhen to Use
Server ComponentData fetching, DB access, sensitive logic
Client ComponentInteractivity, state, browser APIs
Server ActionForm mutations, data updates
SuspenseStreaming, loading states
Route GroupsLayout organization, parallel routes
Parallel RoutesSimultaneous slot rendering
Intercepting RoutesModals, preview panels