> ## Content Index
> Fetch the complete content index at: https://blog.vercanti.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Next.js 完全指南
- URL: https://blog.vercanti.com/next-js-wan-quan-zhi-nan/
- Published: 2026-08-28T14:35:17.000Z
- Updated: 2026-08-28T14:58:30.000Z
- Description: Next.js 14+ 是基于 React 的全栈框架，App Router 是其核心架构，内置 React Server Components、流式渲染、Server Actions 等能力。 相关文档：React完全指南(/react-wan-quan-zhi-nan/) | TypeScript完全指南(/typescript-wan-quan-zhi-nan/) 默认情况下 app/ 下的所有组件都是 Server Components，在服务端渲染后发送 HTML 给客户端，不包含客户端 JavaScript bundle。 在文件顶部添加 '
- Author: yellowdog
- Tags: 前端开发, React生态

> 官方文档：<https://nextjs.org/docs>  
> 适用版本：Next.js 15（2026-05-07 核实）

Next.js 14+ 是基于 React 的全栈框架，App Router 是其核心架构，内置 React Server Components、流式渲染、Server Actions 等能力。

相关文档：[React完全指南](https://blog.vercanti.com/react-wan-quan-zhi-nan/) | [TypeScript完全指南](https://blog.vercanti.com/typescript-wan-quan-zhi-nan/)

---

## 核心概念

### App Router vs Pages Router

| 维度             | App Router（推荐）                             | Pages Router（旧）                     |
| -------------- | ------------------------------------------ | ----------------------------------- |
| 目录             | app/                                       | pages/                              |
| 默认组件类型         | Server Component                           | Client Component                    |
| 数据获取方式         | async/await 直接在组件中使用                       | getServerSideProps / getStaticProps |
| 布局             | 嵌套 layout.tsx 文件                           | \_app.tsx 单一全局布局                    |
| 流式渲染           | 原生支持（loading.tsx / Suspense）               | 不支持                                 |
| Server Actions | 支持                                         | 不支持                                 |
| Metadata API   | export const metadata / generateMetadata() | <Head> 组件                           |

### Server Components vs Client Components

默认情况下 `app/` 下的所有组件都是 **Server Components**，在服务端渲染后发送 HTML 给客户端，不包含客户端 JavaScript bundle。

在文件顶部添加 `'use client'` 声明变为 **Client Component**，拥有完整的 React 能力（状态、事件、Effect）。

| 特性                      | Server Component | Client Component  |
| ----------------------- | ---------------- | ----------------- |
| 声明方式                    | 默认（无需声明）         | 文件顶部 'use client' |
| 运行环境                    | 服务端              | 客户端（也会服务端预渲染）     |
| 直接访问数据库/文件系统            | 可以               | 不可以               |
| 使用 useState / useEffect | 不可以              | 可以                |
| 使用浏览器 API               | 不可以              | 可以                |
| 接收事件处理器（onClick 等）      | 不可以              | 可以                |
| 减少客户端 bundle            | 是                | 否                 |

```tsx
// app/page.tsx - 默认是 Server Component
async function HomePage() {
  // 可以直接访问数据库，无需 API 层
  const posts = await db.posts.findMany()
  return <PostList posts={posts} />
}

// components/Counter.tsx - Client Component
'use client'
import { useState } from 'react'

export function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}

```

### 渲染策略

| 策略        | 触发条件                                                    | 说明                 |
| --------- | ------------------------------------------------------- | ------------------ |
| Static    | 无动态数据获取                                                 | 构建时生成，CDN 缓存，性能最优  |
| Dynamic   | 使用 cookies()、headers()、searchParams，或 cache: 'no-store' | 每次请求时在服务端渲染        |
| Streaming | 使用 Suspense 包裹组件                                        | 逐步发送 HTML，提升首屏感知速度 |

---

## 文件约定

### app/ 目录文件约定

| 文件            | 说明                                        |
| ------------- | ----------------------------------------- |
| page.tsx      | 路由的 UI 页面（必须导出默认组件才能访问该路由）                |
| layout.tsx    | 该路由及其子路由共享的布局，不因路由变化重新渲染                  |
| loading.tsx   | 基于 Suspense 的加载 UI，数据获取期间自动展示             |
| error.tsx     | 错误边界，必须是 Client Component（'use client'）   |
| not-found.tsx | 404 页面，由 notFound() 函数触发或路由不存在时展示         |
| route.ts      | API 端点（Route Handler），不能与同路径的 page.tsx 共存 |
| template.tsx  | 类似 layout.tsx，但每次路由变化都重新挂载                |
| default.tsx   | Parallel Routes 的默认内容                     |
| middleware.ts | 只能放在项目根目录（与 app/ 同级）                      |

### 动态路由

| 文件路径                          | 匹配路由              | params 类型             |
| ----------------------------- | ----------------- | --------------------- |
| app/blog/\[id\]/page.tsx      | /blog/123         | { id: string }        |
| app/blog/\[...slug\]/page.tsx | /blog/a/b/c       | { slug: string\[\] }  |
| app/blog/...slug/page.tsx     | /blog 或 /blog/a/b | { slug?: string\[\] } |

```tsx
// app/blog/[id]/page.tsx
interface Props {
  params: { id: string }
  searchParams: { [key: string]: string | string[] | undefined }
}

export default async function BlogPost({ params, searchParams }: Props) {
  const post = await getPost(params.id)
  return <article>{post.content}</article>
}

```

### Route Groups

用括号包裹目录名（如 `(marketing)`），该目录不会出现在 URL 中，仅用于组织文件结构。

```
app/
  (marketing)/
    about/page.tsx      -> /about
    layout.tsx          -> 仅用于 marketing 组
  (shop)/
    products/page.tsx   -> /products
    layout.tsx          -> 仅用于 shop 组

```

### Parallel Routes 和 Intercepting Routes

**Parallel Routes**（并行路由）：用 `@slot` 目录名定义，在同一个 `layout.tsx` 中同时渲染多个页面。

```
app/
  layout.tsx            -> 引用 @dashboard 和 @analytics
  @dashboard/
    page.tsx
  @analytics/
    page.tsx
  page.tsx

```

```tsx
// app/layout.tsx
export default function Layout({
  children,
  dashboard,
  analytics,
}: {
  children: React.ReactNode
  dashboard: React.ReactNode
  analytics: React.ReactNode
}) {
  return (
    <div>
      {children}
      {dashboard}
      {analytics}
    </div>
  )
}

```

**Intercepting Routes**（拦截路由）：在不离开当前页面的情况下展示另一个路由的内容（如在列表页弹出详情 Modal）。

| 约定                | 说明      |
| ----------------- | ------- |
| (.)photo/\[id\]   | 拦截同级路由  |
| (..)photo/\[id\]  | 拦截上一层路由 |
| (...)photo/\[id\] | 拦截根路由   |

---

## 数据获取

### Server Component 直接 async/await

在 Server Component 中可以直接用 `async/await` 获取数据，无需任何 Hook：

```tsx
// app/posts/page.tsx
async function PostsPage() {
  // 这段代码只在服务端运行，可以直接访问数据库
  const posts = await fetch('https://api.example.com/posts').then(r => r.json())

  return (
    <ul>
      {posts.map((post: Post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

```

### fetch 缓存选项

Next.js 扩展了原生 `fetch`，增加了缓存控制选项。

| 选项                          | 行为                                   |
| --------------------------- | ------------------------------------ |
| cache: 'force-cache'        | 默认值，强制缓存，等同于静态数据                     |
| cache: 'no-store'           | 不缓存，每次请求都重新获取（动态数据）                  |
| next: { revalidate: 60 }    | ISR 策略，60 秒后缓存失效，下次请求时重新生成           |
| next: { tags: \['posts'\] } | 按标签缓存，配合 revalidateTag('posts') 按需清除 |

```tsx
// 静态数据：构建时获取，长期缓存
const staticData = await fetch('/api/config', { cache: 'force-cache' })

// 动态数据：每次请求都获取最新
const dynamicData = await fetch('/api/user', { cache: 'no-store' })

// ISR：定时重新验证
const revalidatedData = await fetch('/api/posts', { next: { revalidate: 3600 } })

```

### generateStaticParams

在动态路由中使用 `generateStaticParams`，在构建时预生成静态页面：

```tsx
// app/blog/[id]/page.tsx
export async function generateStaticParams() {
  const posts = await fetch('/api/posts').then(r => r.json())

  return posts.map((post: Post) => ({
    id: post.id.toString(),
  }))
}

// 访问未预生成的路径时的行为
export const dynamicParams = true  // 动态生成（默认）
// export const dynamicParams = false  // 返回 404

```

### Server Actions

Server Actions 允许在客户端组件中直接调用服务端函数，主要用于表单提交和数据变更。

```tsx
// app/actions.ts
'use server'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  await db.posts.create({ data: { title } })
  revalidatePath('/posts')  // 清除缓存，触发页面重新生成
}

// app/posts/new/page.tsx - 可以是 Server Component
import { createPost } from '../actions'

export default function NewPost() {
  return (
    <form action={createPost}>
      <input name="title" />
      <button type="submit">发布</button>
    </form>
  )
}

```

在 Client Component 中使用 Server Actions：

```tsx
'use client'
import { createPost } from '../actions'
import { useTransition } from 'react'

export function PostForm() {
  const [isPending, startTransition] = useTransition()

  function handleSubmit(formData: FormData) {
    startTransition(async () => {
      await createPost(formData)
    })
  }

  return (
    <form action={handleSubmit}>
      <input name="title" />
      <button disabled={isPending}>
        {isPending ? '发布中...' : '发布'}
      </button>
    </form>
  )
}

```

---

## API Routes

### Route Handler（app/api/xxx/route.ts）

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

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams
  const page = searchParams.get('page') ?? '1'

  const posts = await getPosts({ page: parseInt(page) })
  return NextResponse.json(posts)
}

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

```

动态路由 Route Handler：

```typescript
// app/api/posts/[id]/route.ts
export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const post = await getPost(params.id)
  if (!post) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 })
  }
  return NextResponse.json(post)
}

```

### NextRequest 常用属性

| 属性/方法                        | 说明                                    |
| ---------------------------- | ------------------------------------- |
| request.url                  | 完整 URL 字符串                            |
| request.nextUrl              | 扩展的 URL 对象（含 pathname、searchParams 等） |
| request.nextUrl.searchParams | URL 查询参数（URLSearchParams 实例）          |
| request.method               | HTTP 方法                               |
| request.headers              | 请求头（Headers 实例）                       |
| request.cookies              | 请求 Cookie                             |
| request.json()               | 解析 JSON 请求体（异步）                       |
| request.text()               | 读取文本请求体（异步）                           |
| request.formData()           | 解析 FormData（异步）                       |

### NextResponse 常用方法

| 方法                                       | 说明            |
| ---------------------------------------- | ------------- |
| NextResponse.json(data, init?)           | 返回 JSON 响应    |
| NextResponse.redirect(url, init?)        | 重定向           |
| NextResponse.rewrite(url)                | 内部重写（URL 不变）  |
| NextResponse.next()                      | 中间件中继续处理请求    |
| response.cookies.set(name, value, opts?) | 在响应上设置 Cookie |
| response.cookies.delete(name)            | 删除 Cookie     |

### 中间件（middleware.ts）

中间件运行在所有路由之前，必须放在项目根目录（与 `app/` 同级）。

```typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token')

  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  return NextResponse.next()
}

// 配置匹配规则（不配置则匹配所有路由）
export const config = {
  matcher: ['/dashboard/:path*', '/api/protected/:path*'],
}

```

---

## 性能优化

### next/image

`<Image>` 组件自动进行图片优化：懒加载、格式转换（WebP/AVIF）、尺寸调整、避免 CLS。

| 属性          | 类型                        | 必填         | 说明                               |       |
| ----------- | ------------------------- | ---------- | -------------------------------- | ----- |
| src         | string \| StaticImageData | 是          | 图片路径或导入的静态图片                     |       |
| alt         | string                    | 是          | 无障碍描述                            |       |
| width       | number                    | 是（非 fill）  | 显示宽度（px）                         |       |
| height      | number                    | 是（非 fill）  | 显示高度（px）                         |       |
| fill        | boolean                   | —          | 填充父容器（父容器需 position: relative）   |       |
| sizes       | string                    | —          | 响应式图片尺寸提示（类似 <img sizes>）        |       |
| priority    | boolean                   | —          | 预加载，用于 LCP 图片，禁用懒加载              |       |
| quality     | number                    | —          | 压缩质量（1-100，默认 75）                |       |
| placeholder | 'blur' \| 'empty'         | 'data:...' | —                                | 加载占位符 |
| blurDataURL | string                    | —          | placeholder="blur" 时的模糊预览 base64 |       |
| loading     | 'lazy' \| 'eager'         | —          | 加载策略（默认 lazy）                    |       |
| unoptimized | boolean                   | —          | 跳过 Next.js 图片优化                  |       |
| style       | object                    | —          | 内联样式                             |       |
| className   | string                    | —          | CSS 类名                           |       |
| onLoad      | function                  | —          | 图片加载完成回调                         |       |
| onError     | function                  | —          | 图片加载失败回调                         |       |

```tsx
import Image from 'next/image'
import heroImg from '@/public/hero.jpg'

// 固定尺寸
<Image src={heroImg} alt="Hero" width={800} height={400} priority />

// 响应式填充
<div style={{ position: 'relative', height: '400px' }}>
  <Image
    src="/banner.jpg"
    alt="Banner"
    fill
    sizes="(max-width: 768px) 100vw, 50vw"
    style={{ objectFit: 'cover' }}
  />
</div>

```

### next/font

自动优化字体加载，消除布局偏移，字体文件托管在本地。

```tsx
// app/layout.tsx
import { Inter, Noto_Sans_SC } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  variable: '--font-inter',
})

const notoSansSC = Noto_Sans_SC({
  subsets: ['chinese-simplified'],
  weight: ['400', '700'],
  variable: '--font-noto',
})

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh" className={`${inter.variable} ${notoSansSC.variable}`}>
      <body>{children}</body>
    </html>
  )
}

```

### next/link

`<Link>` 组件在视口内自动预取页面资源，实现无感知导航。

| 属性       | 类型                  | 默认值   | 说明                             |
| -------- | ------------------- | ----- | ------------------------------ |
| href     | string \| UrlObject | —     | 目标路由（必填）                       |
| replace  | boolean             | false | 替换 history 记录而非 push           |
| scroll   | boolean             | true  | 导航后滚动到页面顶部                     |
| prefetch | boolean \| null     | null  | null 视口预取，true 强制预取，false 禁用   |
| shallow  | boolean             | —     | Pages Router 选项，App Router 不适用 |

```tsx
import Link from 'next/link'

<Link href="/about">关于</Link>
<Link href={{ pathname: '/blog/[id]', query: { id: '123' } }}>文章</Link>
<Link href="/external" prefetch={false}>不预取此链接</Link>

```

---

## 最佳实践

### Server / Client 组件边界设计

将 Client Component 尽量下移到叶节点，让大部分组件保持 Server Component：

```
app/page.tsx (Server)
  └── PostList (Server) - 获取数据、渲染列表
        └── PostCard (Server) - 展示卡片内容
              └── LikeButton (Client) - 只有这个需要交互

```

### 数据获取放在尽可能靠近使用的位置

```tsx
// 推荐：在用到数据的 Server Component 内部直接获取
async function PostCard({ postId }: { postId: string }) {
  const post = await getPost(postId)  // 靠近使用位置
  return <div>{post.title}</div>
}

// 不推荐：在顶层获取所有数据再逐层传递（prop drilling）

```

---

## 踩坑与注意事项

### Server / Client 边界错误

Server Component 不能导入 Client Component 中的浏览器 API，但 Client Component 可以导入 Server Component（作为 `children` props 传入）。

```tsx
// 错误：Server Component 中使用 useState
// app/page.tsx（Server Component）
import { useState } from 'react'  // 报错：不能在 Server Component 使用

// 正确：只在有 'use client' 的文件中使用

```

Context Provider 必须是 Client Component，但可以包裹 Server Component：

```tsx
// providers.tsx
'use client'
import { ThemeProvider } from './theme-context'

export function Providers({ children }: { children: React.ReactNode }) {
  return <ThemeProvider>{children}</ThemeProvider>
}

// layout.tsx（Server Component）
import { Providers } from './providers'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>  {/* children 仍是 Server Component */}
      </body>
    </html>
  )
}

```

### cookies() 和 headers() 的使用限制

`cookies()` 和 `headers()` 来自 `next/headers`，只能在以下位置使用：

| 可以使用                                             | 不能使用                     |
| ------------------------------------------------ | ------------------------ |
| Server Component                                 | Client Component         |
| Route Handler（route.ts）                          | generateStaticParams     |
| Server Action                                    | 静态渲染的布局/页面（使用后会强制转为动态渲染） |
| Middleware（使用 request.cookies / request.headers） | —                        |

```tsx
// app/profile/page.tsx
import { cookies, headers } from 'next/headers'

export default async function ProfilePage() {
  // 使用 cookies() 会使这个页面变为动态渲染
  const cookieStore = cookies()
  const token = cookieStore.get('auth-token')

  const headersList = headers()
  const userAgent = headersList.get('user-agent')

  // ...
}

```

### notFound() 必须在 Server Component 或 Route Handler 中调用

```tsx
import { notFound } from 'next/navigation'

export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await getPost(params.id)

  if (!post) {
    notFound()  // 触发同级或最近父级的 not-found.tsx
  }

  return <article>{post.content}</article>
}

```

### Streaming 与 error.tsx

`error.tsx` 必须是 Client Component（需要使用 `useEffect` 访问错误对象），且只能捕获其子树内的错误，不能捕获同级 `layout.tsx` 的错误。

```tsx
// app/blog/error.tsx
'use client'
import { useEffect } from 'react'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    console.error(error)
  }, [error])

  return (
    <div>
      <p>出错了</p>
      <button onClick={reset}>重试</button>
    </div>
  )
}

```

---

## 最佳实践

**默认使用 Server Components，仅在需要时降级为 Client Components**：Server Components 在服务端渲染，零 JS bundle，可直接访问数据库和文件系统。只有需要浏览器 API、事件处理、useState/useEffect 时才加 `'use client'`。

```tsx
// Server Component（默认，不需要声明）
async function UserList() {
  const users = await db.query('SELECT * FROM users')  // 直接访问数据库
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}

// Client Component（仅在需要交互时）
'use client'
function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}

```

**静态内容用 SSG，动态内容用 ISR，实时内容用 SSR**：按数据更新频率选择渲染策略，避免把所有页面都做成 SSR。

```tsx
// ISR：每 60 秒重新生成（适合博客、产品页）
export const revalidate = 60

// SSG：构建时生成，永不过期（适合文档、静态页）
export const revalidate = false

// SSR：每次请求重新渲染（适合个性化、实时数据）
export const dynamic = 'force-dynamic'

```

**数据变更用 Server Actions，避免手动维护 API Route**：Server Actions 可直接从 Client Component 调用服务端逻辑，无需创建 `/api` 路由，并自动处理 CSRF 保护。

```tsx
// app/actions.ts
'use server'
export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  await db.insert({ title })
  revalidatePath('/posts')
}

// 在 Client Component 中直接调用
<form action={createPost}>
  <input name="title" />
  <button type="submit">发布</button>
</form>

```

**使用 `next/image` 替代原生 `<img>`**：自动优化图片格式（WebP/AVIF）、按需懒加载、防止 CLS（布局偏移）。

```tsx
import Image from 'next/image'
// 正确：自动优化
<Image src="/hero.jpg" width={800} height={400} alt="hero" priority />

// 错误：跳过优化
<img src="/hero.jpg" />

```

**路由分组用 `(folder)` 避免 URL 污染**：括号命名的目录不影响 URL 路径，用于组织布局而不暴露目录结构。

```
app/
  (marketing)/         ← URL 中不出现 "marketing"
    layout.tsx         ← 只对 marketing 页面生效的布局
    page.tsx           ← 对应 /
  (dashboard)/
    layout.tsx
    analytics/page.tsx ← 对应 /analytics

```

---

## 常见陷阱

### 陷阱：在 Server Component 中使用浏览器 API

**现象：** 构建报错 "ReferenceError: window is not defined" 或 "localStorage is not defined"。

**原因：** Server Components 在 Node.js 中执行，没有浏览器环境，无法访问 `window`、`document`、`localStorage` 等。

**解决：** 将使用浏览器 API 的代码移到 Client Component（添加 `'use client'`），或使用动态导入加 `ssr: false`。

```tsx
// 动态导入，禁用 SSR
import dynamic from 'next/dynamic'
const BrowserOnlyChart = dynamic(() => import('./Chart'), { ssr: false })

```

### 陷阱：Hydration 错误（水合不匹配）

**现象：** 控制台报 "Hydration failed because the initial UI does not match what was rendered on the server"，页面出现闪烁或内容重置。

**原因：** 服务端渲染的 HTML 与客户端 React 首次渲染的结果不一致，常见原因是在渲染时读取了只在客户端可用的值（如 `Date.now()`、`Math.random()`、`localStorage`）。

**解决：** 将依赖客户端状态的渲染延迟到 `useEffect` 后，或使用 `suppressHydrationWarning`（仅对已知安全的不一致使用）。

```tsx
'use client'
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return null  // 服务端渲染返回 null，避免不一致
return <div>{localStorage.getItem('theme')}</div>

```

### 陷阱：`fetch` 缓存导致数据过期

**现象：** 更新了数据库数据，但页面显示的仍是旧内容，重启服务才恢复正常。

**原因：** Next.js 扩展了原生 `fetch`，默认对所有请求使用强缓存（`cache: 'force-cache'`），即构建后永久缓存。

**解决：** 显式设置缓存策略，或使用 `revalidatePath` / `revalidateTag` 手动清除缓存。

```tsx
// 不缓存
fetch(url, { cache: 'no-store' })

// ISR：60 秒后重新验证
fetch(url, { next: { revalidate: 60 } })

// 带 tag 的缓存，可按需清除
fetch(url, { next: { tags: ['posts'] } })
// 在 Server Action 中清除
revalidateTag('posts')

```

---

## 参见

- [React完全指南](https://blog.vercanti.com/react-wan-quan-zhi-nan/)
- [TypeScript完全指南](https://blog.vercanti.com/typescript-wan-quan-zhi-nan/)
- [TanStack Query 完全指南](https://blog.vercanti.com/tanstack-query-wan-quan-zhi-nan/)
- [TanStack Router 完全指南](https://blog.vercanti.com/tanstack-router-wan-quan-zhi-nan/)