正在加载,请稍候…

Next.js App Router Complete Guide: From Pages to Server Components

Master Next.js 13/14/15 App Router: Server Components, Client Components, layouts, loading states, error handling, data fetching patterns, and migration from Pages Router.

Why the App Router Changes Everything

When Next.js introduced the App Router in version 13, it wasn't just a new file system convention — it was a fundamental rethinking of how React apps are structured and rendered. Two years later, with Next.js 15 stable, the App Router is now the default and recommended approach.

But the mental model shift is real. If you're coming from Pages Router, Server Actions feel strange, "use client" directives seem arbitrary, and the new data fetching approach breaks all your old patterns.

This guide cuts through the confusion. We'll cover how the App Router actually works, when to use Server vs Client Components, and the data fetching patterns that scale.

The Core Concept: React Server Components

The App Router is built on React Server Components (RSC). Understanding RSC is prerequisite to understanding the App Router.

Server Components render on the server and never send JavaScript to the client. They can:

  • Access databases, file systems, and APIs directly
  • Use async/await at the component level
  • Import large dependencies without client bundle impact
  • NOT use hooks, browser APIs, or event handlers

Client Components are the traditional React components you know. They:

  • Run in the browser (and also during SSR)
  • Can use useState, useEffect, event handlers
  • Have access to browser APIs
  • Need "use client" directive at the top of the file

The key insight: Server Components are the default in the App Router. You opt into client behavior, not server behavior.

File System Conventions

app/
├── layout.tsx          # Root layout (always Server Component)
├── page.tsx            # Home page
├── loading.tsx         # Loading UI (Suspense boundary)
├── error.tsx           # Error boundary (must be Client Component)
├── not-found.tsx       # 404 page
├── blog/
│   ├── layout.tsx      # Nested layout
│   ├── page.tsx        # /blog route
│   └── [slug]/
│       ├── page.tsx    # /blog/[slug] route
│       └── loading.tsx # Loading for dynamic posts
└── api/
    └── users/
        └── route.ts    # API route: GET/POST /api/users

Special Files

File Purpose
page.tsx Route UI — makes the route publicly accessible
layout.tsx Shared UI — persists across navigations (state preserved)
loading.tsx Instant loading UI using React Suspense
error.tsx Error boundary for route segments
not-found.tsx Rendered by notFound() function
route.ts API endpoint — cannot coexist with page.tsx
template.tsx Like layout but re-mounts on navigation
middleware.ts Runs before request (edge runtime)

Data Fetching: The New Way

Forget getServerSideProps and getStaticProps. In the App Router, data fetching happens directly in components:

// app/blog/[slug]/page.tsx
// This is a Server Component — async by default

interface Props {
  params: { slug: string }
}

async function getPost(slug: string) {
  const res = await fetch(`https://api.example.com/posts/${slug}`, {
    next: { revalidate: 3600 } // ISR: revalidate every hour
  })
  
  if (!res.ok) {
    notFound() // Renders not-found.tsx
  }
  
  return res.json()
}

export default async function BlogPost({ params }: Props) {
  const post = await getPost(params.slug) // Direct async call!
  
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  )
}

// Optional: Generate static params at build time
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json())
  
  return posts.map((post: any) => ({
    slug: post.slug,
  }))
}

fetch() Caching Behavior

Next.js extends the native fetch() API with caching options:

// Cache indefinitely (static — like old getStaticProps)
fetch(url, { cache: 'force-cache' }) // Default behavior

// Never cache (dynamic — like old getServerSideProps)
fetch(url, { cache: 'no-store' })

// ISR: revalidate after N seconds
fetch(url, { next: { revalidate: 60 } })

// Tag-based revalidation
fetch(url, { next: { tags: ['posts', 'post-123'] } })

// Revalidate by tag (in Server Action or Route Handler)
import { revalidateTag } from 'next/cache'
revalidateTag('posts') // Purge all fetches tagged 'posts'

Database Queries (No fetch needed)

import { db } from '@/lib/database'

export default async function UsersPage() {
  // Direct database call — no API needed!
  const users = await db.query('SELECT * FROM users ORDER BY created_at DESC')
  
  return (
    <ul>
      {users.rows.map(user => (
        <li key={user.id}>{user.name} — {user.email}</li>
      ))}
    </ul>
  )
}

This is the power of Server Components — your database credentials never leave the server.

Server vs Client Components: Decision Framework

Need interactivity (onClick, onChange)?  → Client Component
Need browser APIs (localStorage, window)? → Client Component
Need React hooks (useState, useEffect)?   → Client Component
Need class-based animations?              → Client Component

Fetching data?                → Server Component
Accessing backend resources?  → Server Component
Sensitive data (API keys)?    → Server Component
Large dependencies?           → Server Component (keeps bundle small)
SEO-critical content?         → Server Component

Composing Server and Client Components

// app/dashboard/page.tsx — Server Component
import UserStats from './UserStats'         // Server Component
import InteractiveChart from './InteractiveChart' // Client Component

export default async function Dashboard() {
  const stats = await fetchStats() // Server-side data fetch
  
  return (
    <div>
      <UserStats data={stats} />
      {/* Pass serializable data as props to Client Component */}
      <InteractiveChart initialData={stats.chartData} />
    </div>
  )
}
// app/dashboard/InteractiveChart.tsx
'use client' // Must be at the top of the file

import { useState } from 'react'
import { Chart } from '@/components/Chart'

interface Props {
  initialData: ChartData[]
}

export default function InteractiveChart({ initialData }: Props) {
  const [filter, setFilter] = useState('all')
  
  const filtered = filter === 'all' 
    ? initialData 
    : initialData.filter(d => d.category === filter)
  
  return (
    <div>
      <select onChange={e => setFilter(e.target.value)}>
        <option value="all">All</option>
        <option value="revenue">Revenue</option>
        <option value="users">Users</option>
      </select>
      <Chart data={filtered} />
    </div>
  )
}

Important: You cannot import a Server Component into a Client Component. But you can pass Server Components as children/props:

// This pattern works — Server Component as children prop
'use client'

export default function ClientWrapper({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false)
  
  return (
    <div>
      <button onClick={() => setOpen(!open)}>Toggle</button>
      {open && children} {/* children can be a Server Component */}
    </div>
  )
}

// Usage in Server Component:
<ClientWrapper>
  <ServerComponent />  {/* This is valid! */}
</ClientWrapper>

Layouts and Nested Routing

Layouts wrap their children and persist state across navigations (no re-mount):

// app/layout.tsx — Root layout (required)
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        <nav>/* Navigation */</nav>
        <main>{children}</main>
        <footer>/* Footer */</footer>
      </body>
    </html>
  )
}

// app/dashboard/layout.tsx — Nested layout
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="dashboard">
      <aside>/* Sidebar */</aside>
      <section>{children}</section>
    </div>
  )
}

Parallel Routes

Render multiple pages in the same layout simultaneously:

app/
└── dashboard/
    ├── layout.tsx
    ├── page.tsx
    ├── @analytics/     # Parallel route slot
    │   └── page.tsx
    └── @team/          # Parallel route slot
        └── page.tsx
// app/dashboard/layout.tsx
export default function Layout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  team: React.ReactNode
}) {
  return (
    <div>
      {children}
      <div className="widgets">
        {analytics}
        {team}
      </div>
    </div>
  )
}

Server Actions: Forms Without API Routes

Server Actions let you run server-side code directly from form submissions or event handlers:

// app/contact/actions.ts
'use server'

import { z } from 'zod'
import { revalidatePath } from 'next/cache'

const ContactSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  message: z.string().min(10),
})

export async function submitContact(formData: FormData) {
  const parsed = ContactSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  })
  
  if (!parsed.success) {
    return { error: 'Invalid form data', issues: parsed.error.issues }
  }
  
  // Direct database insert — no API needed
  await db.query(
    'INSERT INTO contacts (name, email, message) VALUES ($1, $2, $3)',
    [parsed.data.name, parsed.data.email, parsed.data.message]
  )
  
  revalidatePath('/admin/contacts')
  return { success: true }
}
// app/contact/page.tsx — Server Component using Server Action
import { submitContact } from './actions'

export default function ContactPage() {
  return (
    <form action={submitContact}> {/* Direct Server Action reference */}
      <input name="name" type="text" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit">Send</button>
    </form>
  )
}

For progressive enhancement with useActionState:

'use client'

import { useActionState } from 'react'
import { submitContact } from './actions'

export default function ContactForm() {
  const [state, action, pending] = useActionState(submitContact, null)
  
  return (
    <form action={action}>
      {state?.error && <p className="error">{state.error}</p>}
      {state?.success && <p className="success">Message sent!</p>}
      <input name="name" type="text" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit" disabled={pending}>
        {pending ? 'Sending...' : 'Send'}
      </button>
    </form>
  )
}

Metadata and SEO

// Static metadata
export const metadata = {
  title: 'Blog',
  description: 'Read our latest articles',
}

// Dynamic metadata
export async function generateMetadata({ params }: Props) {
  const post = await getPost(params.slug)
  
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.coverImage }],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
    },
  }
}

Migrating from Pages Router

Pages Router App Router
pages/index.tsx app/page.tsx
pages/_app.tsx app/layout.tsx
pages/_document.tsx app/layout.tsx (HTML shell)
getStaticProps async Server Component
getServerSideProps async Server Component + cache: 'no-store'
getStaticPaths generateStaticParams
pages/api/route.ts app/api/route/route.ts

Route Handlers (API Routes)

// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)
  const page = Number(searchParams.get('page') ?? 1)
  
  const users = await db.getUsers({ page, limit: 20 })
  
  return NextResponse.json({ users, page })
}

export async function POST(request: NextRequest) {
  const body = await request.json()
  
  const user = await db.createUser(body)
  
  return NextResponse.json(user, { status: 201 })
}

Performance: Streaming and Suspense

// app/dashboard/page.tsx
import { Suspense } from 'react'
import UserProfile from './UserProfile'
import RecentOrders from './RecentOrders'
import Analytics from './Analytics'

export default function Dashboard() {
  return (
    <div>
      {/* Fast — renders immediately */}
      <h1>Dashboard</h1>
      
      {/* Each Suspense boundary streams independently */}
      <Suspense fallback={<ProfileSkeleton />}>
        <UserProfile /> {/* Async Server Component */}
      </Suspense>
      
      <Suspense fallback={<OrdersSkeleton />}>
        <RecentOrders /> {/* Streams when ready */}
      </Suspense>
      
      <Suspense fallback={<AnalyticsSkeleton />}>
        <Analytics /> {/* Doesn't block other content */}
      </Suspense>
    </div>
  )
}

This streaming approach means users see content progressively — the fast parts appear immediately while slower data fetches complete in the background.

Common Pitfalls

1. "use client" propagation: Adding "use client" to a file makes all its imports Client Components too. Keep Client Components as leaves in the tree.

2. Serialization: Data passed from Server to Client Components must be serializable (no functions, Dates become strings, etc.).

3. Context in Server Components: React context doesn't work in Server Components. Use it in Client Component wrappers.

4. cookies() and headers(): These make a route dynamic. Call them only when needed.

// This makes the entire route dynamic:
import { cookies } from 'next/headers'
const token = cookies().get('auth-token')

// Better: pass as prop from layout or middleware

5. Client Component in Server Component: You can't import server-only code (like database clients) in Client Components — it would leak to the browser bundle.

The App Router has a steep learning curve, but once the Server/Client Component mental model clicks, building Next.js applications becomes remarkably powerful. The elimination of API routes for most data operations alone simplifies full-stack development significantly.

→ Parse and analyze URLs with the URL Parser.