基于 Next.js v16.2.6 官方文档与源码,系统梳理核心概念、最佳实践与实战示例
目录
- 什么是 Next.js?
- 安装与初始化
- 项目结构详解
- 文件路由系统:Layouts 与 Pages
- 导航与链接
- Server Components vs Client Components
- 数据获取
- 数据变更:Server Functions
- 缓存与重新验证
- 错误处理
- 样式方案
- 图片与字体优化
- SEO 与元数据
- Route Handlers(API 接口)
- 认证与授权
- 环境变量
- 部署与自托管
- 测试
- 实战示例:从简单到高级
- 综合知识地图
- 常见问题与最佳实践
- 总结
一、什么是 Next.js?{#what-is-nextjs}
Next.js 是基于 React 的全栈 Web 应用框架,由 Vercel 开源维护,目前在 GitHub 上拥有 139k Star。它扩展了 React 的最新特性,并集成了基于 Rust 的高性能 JavaScript 工具链,实现最快的构建速度。
┌──────────────────────────────────────────────────────────┐
│ Next.js 能力全景 │
├────────────────┬─────────────────────────────────────────┤
│ 前端能力 │ React 组件、CSR、SSR、SSG、ISR、PPR │
│ 路由系统 │ 文件系统路由、动态路由、嵌套布局 │
│ API 能力 │ Route Handlers、Server Functions │
│ 数据层 │ 数据获取、缓存、重验证、流式渲染 │
│ 优化 │ 图片、字体、脚本、包体积、SEO │
│ 部署 │ Node.js、Docker、静态导出、适配器 │
└────────────────┴─────────────────────────────────────────┘
App Router vs Pages Router
Next.js 提供两套路由系统:
┌─────────────────────────────────────────────────────────────┐
│ 两种路由器对比 │
├─────────────────────┬───────────────────────────────────────┤
│ App Router(推荐) │ Pages Router(遗留) │
├─────────────────────┼───────────────────────────────────────┤
│ 目录:/app │ 目录:/pages │
│ 默认 Server Components│ 默认 Client Components │
│ 支持流式渲染 Streaming│ 有限流式支持 │
│ 嵌套布局 │ 单层布局 │
│ React 19 / Canary │ 使用 package.json 里的 React 版本 │
│ 更多新特性 │ 稳定、向后兼容 │
└─────────────────────┴───────────────────────────────────────┘
本文全程基于 App Router,这也是官方推荐的方式。
二、安装与初始化{#installation}
系统要求
- Node.js ≥ 20.9
- 操作系统:macOS、Windows(含 WSL)、Linux
快速开始(推荐)
# 一键创建并启动(使用默认配置,跳过所有提示)
pnpm create next-app@latest my-app --yes
cd my-app
pnpm dev
访问 http://localhost:3000 即可看到欢迎页。
默认配置包含:TypeScript、Tailwind CSS、ESLint、App Router、Turbopack(新一代打包器)
自定义创建
pnpm create next-app
会交互式询问:
- TypeScript / JavaScript
- ESLint / Biome / 无
- 是否使用 React Compiler
- 是否使用 Tailwind CSS
- 是否使用
src/目录 - 是否使用 App Router
- 自定义 import 别名(默认
@/*)
手动安装
pnpm add next@latest react@latest react-dom@latest
在 package.json 添加脚本:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
}
}
创建最基础的两个文件:
// app/layout.tsx — 根布局(必须)
export default function RootLayout({
children,
}: { children: React.ReactNode }) {
return (
<html lang="zh">
<body>{children}</body>
</html>
)
}
// app/page.tsx — 首页
export default function Page() {
return <h1>Hello, Next.js!</h1>
}
三、项目结构详解{#project-structure}
my-app/
├── app/ ← App Router 核心目录
│ ├── layout.tsx ← 根布局(必须)
│ ├── page.tsx ← 首页 /
│ ├── globals.css ← 全局样式
│ ├── favicon.ico ← 网站图标
│ ├── blog/
│ │ ├── layout.tsx ← /blog 共享布局
│ │ ├── page.tsx ← /blog 页面
│ │ └── [slug]/
│ │ └── page.tsx ← /blog/:slug 动态页面
│ └── api/
│ └── route.ts ← API 路由
├── public/ ← 静态资源(图片、字体等)
├── components/ ← 共享组件(惯例命名)
├── lib/ ← 工具函数、数据库操作
├── next.config.ts ← Next.js 配置
├── tsconfig.json
├── tailwind.config.ts
└── package.json
特殊文件约定
┌────────────────────────────────────────────────────────────┐
│ 路由特殊文件一览 │
├─────────────────────┬──────────────────────────────────────┤
│ 文件名 │ 作用 │
├─────────────────────┼──────────────────────────────────────┤
│ layout.tsx │ 共享布局(导航栏、侧边栏等) │
│ page.tsx │ 页面内容(公开路由入口) │
│ loading.tsx │ 加载中 UI(Suspense 骨架屏) │
│ error.tsx │ 错误边界 UI │
│ not-found.tsx │ 404 页面 │
│ route.ts │ API 端点(GET/POST/PUT/DELETE) │
│ template.tsx │ 每次导航都重新挂载的布局 │
│ default.tsx │ 并行路由降级页面 │
└─────────────────────┴──────────────────────────────────────┘
组件渲染层次结构
layout.tsx
└── template.tsx
└── error.tsx (ErrorBoundary)
└── loading.tsx (Suspense)
└── not-found.tsx (ErrorBoundary)
└── page.tsx
路由组织策略
1. 私有文件夹(不纳入路由):以 _ 开头
app/
├── _components/ ← 私有,不可路由
├── _utils/ ← 私有,不可路由
└── page.tsx
2. 路由组(不影响 URL):用 () 包裹
app/
├── (marketing)/
│ ├── about/page.tsx ← URL: /about
│ └── layout.tsx
├── (shop)/
│ ├── products/page.tsx ← URL: /products
│ └── layout.tsx
└── page.tsx ← URL: /
这样可以为不同区域设置不同的布局,而 URL 保持简洁。
四、文件路由系统:Layouts 与 Pages{#layouts-and-pages}
路由与文件夹的对应关系
app/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
├── blog/
│ ├── page.tsx → /blog
│ └── [slug]/
│ └── page.tsx → /blog/:slug (动态路由)
└── shop/
└── [...slug]/
└── page.tsx → /shop/a/b/c (捕获所有路由)
布局嵌套
布局是页面间共享的 UI,导航时保持状态、不重新渲染。
app/layout.tsx(根布局)
└── app/blog/layout.tsx(Blog 区域布局)
└── app/blog/[slug]/page.tsx(具体页面)
// app/blog/layout.tsx
export default function BlogLayout({
children,
}: { children: React.ReactNode }) {
return (
<div>
<nav>博客导航栏</nav>
<main>{children}</main>
</div>
)
}
动态路由
// app/blog/[slug]/page.tsx
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await getPost(slug)
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}
搜索参数
// app/products/page.tsx
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<{ page?: string; category?: string }>
}) {
const { page = '1', category } = await searchParams
const products = await getProducts({ page: Number(page), category })
return <ProductList products={products} />
}
TypeScript 路由辅助类型
Next.js 提供全局可用的 PageProps 和 LayoutProps,无需手动导入:
// app/blog/[slug]/page.tsx
export default async function Page(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params
return <h1>博客文章:{slug}</h1>
}
五、导航与链接{#linking-and-navigating}
Next.js 导航工作原理
用户点击链接
│
▼
┌─────────────────────────────────────────────────┐
│ 客户端导航流程 │
│ │
│ 1. 检查预取缓存(viewport 进入时已预取) │
│ ├── 命中缓存 → 立即展示(近似瞬间) │
│ └── 未命中 → 发起服务器请求 │
│ │
│ 2. 保留共享布局(不重新渲染) │
│ │
│ 3. 展示 loading.tsx 骨架屏(若有) │
│ │
│ 4. 服务器返回新页面内容(RSC Payload) │
│ │
│ 5. 替换页面内容,滚动到顶部 │
└─────────────────────────────────────────────────┘
<Link> 组件
import Link from 'next/link'
// 基础用法
<Link href="/blog">博客</Link>
// 动态路由
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
// 禁用预取(大列表场景节省资源)
<Link prefetch={false} href="/blog">博客</Link>
编程式导航
'use client'
import { useRouter } from 'next/navigation'
export default function LoginButton() {
const router = useRouter()
const handleLogin = async () => {
await login()
router.push('/dashboard') // 跳转
// router.replace('/dashboard') // 替换历史记录
// router.back() // 后退
// router.refresh() // 刷新当前页
}
return <button onClick={handleLogin}>登录</button>
}
慢网络下的加载指示器
// app/ui/loading-indicator.tsx
'use client'
import { useLinkStatus } from 'next/link'
export default function LoadingIndicator() {
const { pending } = useLinkStatus()
return (
<span
style={{
display: pending ? 'inline-block' : 'none',
width: 16,
height: 16,
border: '2px solid #ccc',
borderTopColor: '#333',
borderRadius: '50%',
animation: 'spin 0.6s linear infinite',
}}
/>
)
}
六、Server Components vs Client Components{#server-and-client-components}
这是 Next.js App Router 最核心、最需要理解的概念。
概念图解
┌────────────────────────────────────────────────────────────────┐
│ 组件运行环境对比 │
├──────────────────────────┬─────────────────────────────────────┤
│ Server Components │ Client Components │
│ (默认,无需标记) │ (需加 'use client' 指令) │
├──────────────────────────┼─────────────────────────────────────┤
│ ✅ 直接访问数据库 │ ✅ useState、useEffect 等 Hooks │
│ ✅ 使用 API Keys(保密) │ ✅ 事件处理(onClick 等) │
│ ✅ 减少客户端 JS 体积 │ ✅ 浏览器 API(localStorage 等) │
│ ✅ 在服务器运行,更快 │ ✅ 自定义 Hooks │
│ ❌ 无法使用 Hooks │ ❌ 无法直接访问数据库 │
│ ❌ 无法处理交互事件 │ ❌ 无法使用服务器专属 API │
└──────────────────────────┴─────────────────────────────────────┘
如何选择?
需要交互(点击、输入、状态)?
是 → Client Component ('use client')
否 →
需要数据库/API/密钥?
是 → Server Component(默认)
否 → 均可,优先 Server Component
RSC 渲染流程
服务器
│
├─ 1. 渲染 Server Components → RSC Payload(二进制格式)
│
├─ 2. 预渲染 HTML(含 Client Component 占位符)
│
└─ 发送给客户端
│
客户端 │
├─ 3. 展示 HTML(快速非交互预览)
│
├─ 4. 下载 JS,水合(Hydration)Client Components
│
└─ 5. 应用可交互
基础示例
// app/page.tsx — Server Component(默认)
import LikeButton from '@/components/like-button'
import { getPost } from '@/lib/data'
export default async function Page({
params,
}: { params: Promise<{ id: string }> }) {
const { id } = await params
const post = await getPost(id) // 服务器端直接读数据库
return (
<div>
<h1>{post.title}</h1>
<p>{post.content}</p>
{/* Server Component 传 props 给 Client Component */}
<LikeButton likes={post.likes} />
</div>
)
}
// components/like-button.tsx — Client Component
'use client'
import { useState } from 'react'
export default function LikeButton({ likes }: { likes: number }) {
const [count, setCount] = useState(likes)
return (
<button onClick={() => setCount(c => c + 1)}>
❤️ {count}
</button>
)
}
将 Server Components 嵌入 Client Components(高级)
// ✅ 正确:通过 children prop 传入
// components/modal.tsx(Client Component)
'use client'
import { useState } from 'react'
export default function Modal({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>打开</button>
{open && <div className="modal">{children}</div>}
</>
)
}
// app/page.tsx(Server Component)
import Modal from '@/components/modal'
import Cart from '@/components/cart' // 也是 Server Component
export default function Page() {
return (
<Modal>
<Cart /> {/* Server Component 作为 children 传入 */}
</Modal>
)
}
Context Provider 的处理
// components/theme-provider.tsx
'use client'
import { createContext, useContext } from 'react'
const ThemeContext = createContext<string>('light')
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<ThemeContext.Provider value="dark">
{children}
</ThemeContext.Provider>
)
}
export const useTheme = () => useContext(ThemeContext)
// app/layout.tsx(Server Component 可以渲染 Client Provider)
import { ThemeProvider } from '@/components/theme-provider'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
)
}
防止服务器代码泄漏到客户端
pnpm add server-only
// lib/db.ts
import 'server-only' // 如果被客户端导入,构建时报错
export async function getSecretData() {
return db.query('SELECT * FROM secrets')
}
七、数据获取{#fetching-data}
Server Components 中获取数据
方式 1:fetch API
// app/blog/page.tsx
export default async function BlogPage() {
const res = await fetch('https://api.example.com/posts')
const posts = await res.json()
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
方式 2:ORM/数据库
// app/users/page.tsx
import { db } from '@/lib/db'
export default async function UsersPage() {
const users = await db.query('SELECT * FROM users')
return <UserList users={users} />
}
流式渲染(Streaming)
不使用 Streaming:
页面 ───────────────────────────────────────► 展示(全部加载完)
←─── 数据A ─── 数据B ─── 数据C ───►
使用 Streaming:
HTML 骨架 ─► Suspense 占位符立即展示
数据A 就绪 ─► 替换占位符A
数据B 就绪 ─► 替换占位符B
数据C 就绪 ─► 替换占位符C
方式 1:loading.tsx(整页流式)
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="space-y-4">
{[1,2,3].map(i => (
<div key={i} className="h-20 bg-gray-200 animate-pulse rounded" />
))}
</div>
)
}
方式 2:<Suspense>(细粒度流式)
// app/blog/page.tsx
import { Suspense } from 'react'
import BlogList from '@/components/blog-list'
export default function BlogPage() {
return (
<div>
{/* 立即展示(静态内容) */}
<header>
<h1>我的博客</h1>
<p>分享技术与生活</p>
</header>
{/* 流式加载(动态内容) */}
<Suspense fallback={<BlogSkeleton />}>
<BlogList />
</Suspense>
</div>
)
}
并行数据获取(提升性能)
// ❌ 串行(慢):后一个等前一个
const artist = await getArtist(username) // 1秒
const albums = await getAlbums(username) // 再等 1秒
// 总计 2秒
// ✅ 并行(快):同时发起
const [artist, albums] = await Promise.all([
getArtist(username), // 同时发起
getAlbums(username), // 同时发起
])
// 总计 ~1秒(取最长的那个)
Client Components 中获取数据
使用 SWR(推荐用于客户端数据):
'use client'
import useSWR from 'swr'
const fetcher = (url: string) => fetch(url).then(r => r.json())
export default function UserProfile() {
const { data, error, isLoading } = useSWR('/api/user', fetcher)
if (isLoading) return <div>加载中...</div>
if (error) return <div>加载失败</div>
return <div>欢迎,{data.name}</div>
}
八、数据变更:Server Functions{#mutating-data}
什么是 Server Function?
Server Function 是运行在服务器上的异步函数,可从客户端发起调用。当用于表单提交时,又称 Server Action。
客户端(浏览器) 服务器
│ │
│ ── POST 请求 ──────────────► │ Server Function 执行
│ │ (访问数据库、发送邮件等)
│ ◄── 返回新 UI 或数据 ──────── │
│ │
创建 Server Function
// app/actions.ts
'use server'
import { auth } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
// 1. 验证身份(必须!)
const session = await auth()
if (!session?.user) {
throw new Error('未授权')
}
// 2. 获取表单数据
const title = formData.get('title') as string
const content = formData.get('content') as string
// 3. 写入数据库
await db.post.create({ data: { title, content, userId: session.user.id } })
// 4. 刷新缓存 + 跳转
revalidatePath('/posts')
redirect('/posts')
}
在表单中使用
// app/new-post/page.tsx
import { createPost } from '@/app/actions'
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="标题" required />
<textarea name="content" placeholder="内容" required />
<button type="submit">发布</button>
</form>
)
}
显示提交状态(useActionState)
'use client'
import { useActionState } from 'react'
import { createPost } from '@/app/actions'
const initialState = { message: '' }
export default function PostForm() {
const [state, formAction, pending] = useActionState(createPost, initialState)
return (
<form action={formAction}>
<input name="title" placeholder="标题" required />
<textarea name="content" placeholder="内容" required />
{state?.message && (
<p className="text-red-500">{state.message}</p>
)}
<button disabled={pending}>
{pending ? '发布中...' : '发布'}
</button>
</form>
)
}
在事件处理器中调用
'use client'
import { incrementLike } from './actions'
import { useState } from 'react'
export default function LikeButton({ initialLikes }: { initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes)
return (
<button onClick={async () => {
const updated = await incrementLike()
setLikes(updated)
}}>
❤️ {likes}
</button>
)
}
九、缓存与重新验证{#caching-and-revalidating}
Next.js 的缓存策略是提升性能的关键。开启 Cache Components(推荐)后,使用 use cache 指令管理缓存。
启用 Cache Components
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
缓存层级图
┌─────────────────────────────────────────────────────────────┐
│ 缓存决策流程 │
│ │
│ 组件/函数 │
│ │ │
│ ├── 有 'use cache' → 缓存结果,纳入静态 Shell │
│ │ │
│ ├── 包在 <Suspense> 中 → 请求时流式渲染 │
│ │ │
│ └── 纯确定性操作 → 自动纳入静态 Shell │
└─────────────────────────────────────────────────────────────┘
use cache 指令
// 函数级缓存(数据层)
import { cacheLife, cacheTag } from 'next/cache'
export async function getProducts() {
'use cache'
cacheLife('hours') // 1小时刷新
cacheTag('products') // 标签,便于手动失效
return db.query('SELECT * FROM products')
}
// 组件级缓存(UI层)
export default async function BlogPosts() {
'use cache'
cacheLife('hours')
const posts = await fetch('https://api.example.com/posts').then(r => r.json())
return (
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
)
}
缓存生命周期配置
┌────────────┬──────────┬────────────┬──────────────┐
│ 配置名 │ stale │ revalidate │ expire │
├────────────┼──────────┼────────────┼──────────────┤
│ seconds │ 0 │ 1s │ 60s │
│ minutes │ 5m │ 1m │ 1h │
│ hours │ 5m │ 1h │ 1d │
│ days │ 5m │ 1d │ 1w │
│ weeks │ 5m │ 1w │ 30d │
│ max │ 5m │ 30d │ ~永久 │
└────────────┴──────────┴────────────┴──────────────┘
手动失效(On-Demand Revalidation)
// app/actions.ts
'use server'
import { revalidateTag, updateTag } from 'next/cache'
// 后台刷新(用户看到旧数据,后台重新生成)
export async function updateProduct(id: string, data: any) {
await db.product.update({ where: { id }, data })
revalidateTag('products') // stale-while-revalidate
}
// 立即失效(用于"读己写"场景,用户立刻看到最新数据)
export async function createPost(formData: FormData) {
const post = await db.post.create({ data: { /* ... */ } })
updateTag('posts') // 立即使缓存失效(仅 Server Action 可用)
redirect(`/posts/${post.id}`)
}
静态内容 + 动态内容 + 流式内容同页
PPR(Partial Prerendering)架构图:
┌──────────────────────────────────────────────────────┐
│ 页面(静态 Shell) │
│ ┌───────────────────────────────┐ │
│ │ 导航栏(静态) │ │
│ └───────────────────────────────┘ │
│ ┌───────────────────────────────┐ │
│ │ 博客列表(cached,use cache) │← 缓存1小时 │
│ └───────────────────────────────┘ │
│ ┌───────────────────────────────┐ │
│ │ 用户偏好(动态,流式渲染) │← 请求时流入 │
│ │ ┌──────────────────────────┐ │ │
│ │ │ loading 骨架屏 → 实际内容│ │ │
│ │ └──────────────────────────┘ │ │
│ └───────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
十、错误处理{#error-handling}
预期错误(用返回值处理)
// app/actions.ts(Server Function 中的预期错误)
'use server'
export async function createPost(prevState: any, formData: FormData) {
const title = formData.get('title') as string
if (!title || title.length < 3) {
return { message: '标题至少需要3个字符' } // 返回错误,不抛出
}
const res = await fetch('https://api.example.com/posts', {
method: 'POST',
body: JSON.stringify({ title }),
})
if (!res.ok) {
return { message: '发布失败,请重试' }
}
}
'use client'
import { useActionState } from 'react'
import { createPost } from '@/app/actions'
export function PostForm() {
const [state, formAction, pending] = useActionState(createPost, { message: '' })
return (
<form action={formAction}>
<input name="title" />
{state?.message && <p className="text-red-500">{state.message}</p>}
<button disabled={pending}>发布</button>
</form>
)
}
404 处理
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
export default async function BlogPost({
params,
}: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await getPost(slug)
if (!post) {
notFound() // 展示 not-found.tsx
}
return <article>{post.title}</article>
}
// app/blog/[slug]/not-found.tsx
export default function NotFound() {
return (
<div>
<h2>文章不存在</h2>
<p>您访问的文章已被删除或不存在</p>
</div>
)
}
错误边界(非预期错误)
// app/dashboard/error.tsx
'use client'
export default function ErrorPage({
error,
unstable_retry,
}: {
error: Error & { digest?: string }
unstable_retry: () => void
}) {
return (
<div>
<h2>出错了!</h2>
<p>{error.message}</p>
<button onClick={() => unstable_retry()}>
重试
</button>
</div>
)
}
错误边界层级(从细到粗):
app/dashboard/error.tsx ← 处理 dashboard 段错误
app/error.tsx ← 处理 app 层错误
app/global-error.tsx ← 处理根布局错误(需含 html/body)
十一、样式方案{#css}
Tailwind CSS(推荐)
pnpm add -D tailwindcss @tailwindcss/postcss
// postcss.config.mjs
export default {
plugins: { '@tailwindcss/postcss': {} },
}
/* app/globals.css */
@import 'tailwindcss';
// 在组件中使用
export default function Hero() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-blue-50 to-white">
<h1 className="text-5xl font-bold text-gray-900">
欢迎使用 Next.js
</h1>
<p className="mt-4 text-xl text-gray-600">
构建现代化全栈 Web 应用
</p>
</div>
)
}
CSS Modules(作用域样式)
/* app/blog/blog.module.css */
.article {
max-width: 720px;
margin: 0 auto;
padding: 2rem;
}
.title {
font-size: 2rem;
color: #1a1a1a;
}
import styles from './blog.module.css'
export default function BlogPost() {
return (
<article className={styles.article}>
<h1 className={styles.title}>文章标题</h1>
</article>
)
}
十二、图片与字体优化{#images-and-fonts}
<Image> 组件
传统 <img> 问题 next/image 解决方案
├── 布局抖动(CLS) → 自动预留空间,消除 CLS
├── 不必要的大图 → 按设备自动缩放
├── 慢加载 → 懒加载 + 现代格式(WebP/AVIF)
└── 无模糊占位符 → blur placeholder 平滑过渡
本地图片(自动获取尺寸):
import Image from 'next/image'
import profilePic from './profile.png' // 静态导入,自动宽高
export default function Avatar() {
return (
<Image
src={profilePic}
alt="用户头像"
placeholder="blur" // 加载时显示模糊占位
/>
)
}
远程图片(需配置白名单):
// app/page.tsx
import Image from 'next/image'
export default function Page() {
return (
<Image
src="https://s3.amazonaws.com/my-bucket/photo.jpg"
alt="图片描述"
width={800}
height={600}
priority // 首屏图片加 priority,关闭懒加载
/>
)
}
// next.config.ts — 配置远程图片白名单
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 's3.amazonaws.com',
pathname: '/my-bucket/**',
},
],
},
}
填充父容器(响应式图片):
<div className="relative h-64 w-full">
<Image
src="/banner.jpg"
alt="横幅"
fill
className="object-cover"
/>
</div>
字体优化
// app/layout.tsx
import { Inter, Noto_Sans_SC } from 'next/font/google'
// 英文字体
const inter = Inter({ subsets: ['latin'] })
// 中文字体(注意:中文字体较大,建议指定 subset)
const notoSansSC = Noto_Sans_SC({
weight: ['400', '700'],
subsets: ['chinese-simplified'],
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh" className={`${inter.variable} ${notoSansSC.variable}`}>
<body>{children}</body>
</html>
)
}
本地字体:
import localFont from 'next/font/local'
const myFont = localFont({
src: [
{ path: './fonts/MyFont-Regular.woff2', weight: '400' },
{ path: './fonts/MyFont-Bold.woff2', weight: '700' },
],
variable: '--font-my',
})
字体文件会在构建时自动下载并托管到同域,用户浏览器不会向 Google Fonts 发送任何请求,提升隐私与性能。
十三、SEO 与元数据{#metadata}
静态元数据
// app/blog/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: '技术博客 | MyApp',
description: '分享前端开发、全栈技术与实战经验',
keywords: ['Next.js', 'React', 'TypeScript'],
authors: [{ name: '张三' }],
openGraph: {
title: '技术博客',
description: '分享前端开发经验',
images: ['/og-image.jpg'],
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: '技术博客',
},
}
动态元数据(依赖数据)
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { cache } from 'react'
// 用 cache 包裹,同一请求内避免重复查询
const getPost = cache(async (slug: string) => {
return db.post.findUnique({ where: { slug } })
})
export async function generateMetadata({
params,
}: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
return {
title: `${post?.title} | 技术博客`,
description: post?.summary,
openGraph: {
images: [post?.coverImage ?? '/default-og.jpg'],
},
}
}
export default async function Page({
params,
}: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await getPost(slug) // 命中缓存,不重复请求
return <article>{post?.content}</article>
}
生成 OG 图片
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPost } from '@/lib/data'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function OGImage({
params,
}: { params: { slug: string } }) {
const post = await getPost(params.slug)
return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '80px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
width: '100%',
height: '100%',
}}
>
<p style={{ color: 'rgba(255,255,255,0.8)', fontSize: 28 }}>
技术博客
</p>
<h1 style={{ color: 'white', fontSize: 72, fontWeight: 'bold' }}>
{post?.title}
</h1>
</div>
)
)
}
站点地图与 robots.txt
// app/sitemap.ts
import { MetadataRoute } from 'next'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getPosts()
return [
{ url: 'https://example.com', changeFrequency: 'daily', priority: 1 },
...posts.map(post => ({
url: `https://example.com/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: 'weekly' as const,
priority: 0.8,
})),
]
}
// app/robots.ts
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: '/admin/' },
sitemap: 'https://example.com/sitemap.xml',
}
}
十四、Route Handlers(API 接口){#route-handlers}
Route Handlers 是 App Router 中的 API 端点,等价于 Pages Router 的 API Routes。
基础 CRUD 示例
// app/api/posts/route.ts
import { NextRequest } from 'next/server'
// GET /api/posts
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const page = Number(searchParams.get('page') ?? 1)
const posts = await db.post.findMany({
skip: (page - 1) * 10,
take: 10,
orderBy: { createdAt: 'desc' },
})
return Response.json({ posts, page })
}
// POST /api/posts
export async function POST(request: NextRequest) {
const body = await request.json()
const { title, content } = body
if (!title) {
return Response.json({ error: '标题不能为空' }, { status: 400 })
}
const post = await db.post.create({
data: { title, content },
})
return Response.json(post, { status: 201 })
}
// app/api/posts/[id]/route.ts
import type { NextRequest } from 'next/server'
// PUT /api/posts/:id
export async function PUT(
request: NextRequest,
ctx: RouteContext<'/api/posts/[id]'>
) {
const { id } = await ctx.params
const body = await request.json()
const post = await db.post.update({
where: { id },
data: body,
})
return Response.json(post)
}
// DELETE /api/posts/:id
export async function DELETE(
_req: NextRequest,
ctx: RouteContext<'/api/posts/[id]'>
) {
const { id } = await ctx.params
await db.post.delete({ where: { id } })
return new Response(null, { status: 204 })
}
静态缓存的 Route Handler
// app/api/config/route.ts
export const dynamic = 'force-static' // 构建时预渲染
export async function GET() {
return Response.json({
version: '1.0.0',
features: ['blog', 'comments', 'search'],
})
}
设置响应 Headers
export async function GET() {
const data = await fetchData()
return new Response(JSON.stringify(data), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, s-maxage=60',
},
})
}
十五、认证与授权{#authentication}
认证是大多数应用必备的功能,下图展示了完整的认证流程:
┌──────────────────────────────────────────────────────────────┐
│ Next.js 认证流程全景 │
│ │
│ 用户访问受保护路由 │
│ │ │
│ ▼ │
│ ┌─────────────┐ 检查 session cookie │
│ │ Proxy │──────────────────────────────────────────► │
│ │ (proxy.ts) │ │
│ └─────────────┘ │
│ │ 无效/过期 │
│ ▼ │
│ 重定向到 /login │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ 登录页面 │ │
│ │ 1. 用户填写账号密码 │ │
│ │ 2. Server Action 验证 │ │
│ │ 3. 创建 JWT Session,写入 httpOnly Cookie │ │
│ │ 4. 重定向到 Dashboard │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ Dashboard 中的每个数据请求 → DAL.verifySession() → 验证权限 │
└──────────────────────────────────────────────────────────────┘
推荐认证库
对于生产环境,强烈推荐使用成熟的认证库而非自己实现,可选:Auth.js(NextAuth)、Clerk、Auth0、Better Auth。
Proxy 路由保护
// proxy.ts(项目根目录)
import { NextRequest, NextResponse } from 'next/server'
import { decrypt } from '@/lib/session'
import { cookies } from 'next/headers'
const protectedRoutes = ['/dashboard', '/profile', '/settings']
const publicRoutes = ['/login', '/signup', '/']
export default async function proxy(req: NextRequest) {
const path = req.nextUrl.pathname
const isProtectedRoute = protectedRoutes.some(r => path.startsWith(r))
const isPublicRoute = publicRoutes.includes(path)
const cookie = (await cookies()).get('session')?.value
const session = await decrypt(cookie)
// 未登录访问受保护页面 → 跳转登录
if (isProtectedRoute && !session?.userId) {
return NextResponse.redirect(new URL('/login', req.nextUrl))
}
// 已登录访问登录页 → 跳转 Dashboard
if (isPublicRoute && session?.userId && path === '/login') {
return NextResponse.redirect(new URL('/dashboard', req.nextUrl))
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
}
数据访问层(DAL)
// lib/dal.ts — 集中管理授权逻辑
import 'server-only'
import { cache } from 'react'
import { cookies } from 'next/headers'
import { decrypt } from './session'
import { redirect } from 'next/navigation'
export const verifySession = cache(async () => {
const cookie = (await cookies()).get('session')?.value
const session = await decrypt(cookie)
if (!session?.userId) {
redirect('/login')
}
return { isAuth: true, userId: session.userId as string }
})
export const getUser = cache(async () => {
const { userId } = await verifySession()
const user = await db.user.findUnique({
where: { id: userId },
select: { id: true, name: true, email: true, role: true },
// 注意:永远不要返回 password 字段
})
return user
})
Server Action 中的授权检查
// app/actions.ts
'use server'
import { verifySession } from '@/lib/dal'
export async function deletePost(postId: string) {
const session = await verifySession() // 未认证会自动 redirect
// 额外权限检查:只能删除自己的文章
const post = await db.post.findUnique({ where: { id: postId } })
if (post?.userId !== session.userId) {
throw new Error('无权删除此文章')
}
await db.post.delete({ where: { id: postId } })
}
十六、环境变量{#environment-variables}
文件优先级(从高到低)
process.env
└── .env.$(NODE_ENV).local (如 .env.development.local)
└── .env.local (test 环境不加载)
└── .env.$(NODE_ENV) (如 .env.production)
└── .env
服务端 vs 客户端变量
# .env.local
# 服务端专用(不暴露给浏览器)
DATABASE_URL=postgres://localhost/mydb
SECRET_API_KEY=sk-xxxxx
# 客户端可用(NEXT_PUBLIC_ 前缀,构建时内联到 JS bundle)
NEXT_PUBLIC_APP_URL=https://myapp.com
NEXT_PUBLIC_ANALYTICS_ID=G-XXXXXXXX
// 服务端组件(安全)
export default async function Page() {
const db = await connect(process.env.DATABASE_URL) // ✅ 服务端可用
// ...
}
// 客户端组件
'use client'
export default function Analytics() {
// ✅ NEXT_PUBLIC_ 前缀的变量可用
const id = process.env.NEXT_PUBLIC_ANALYTICS_ID
// ❌ 非 NEXT_PUBLIC_ 在客户端为 undefined
const secret = process.env.SECRET_API_KEY // undefined!
}
运行时环境变量(动态读取)
// 使用 connection() 确保在请求时读取,不在构建时固化
import { connection } from 'next/server'
export default async function Component() {
await connection()
const value = process.env.MY_RUNTIME_CONFIG // 每次请求读取
return <div>{value}</div>
}
十七、部署与自托管{#deployment}
部署方式对比
┌───────────────────┬────────────────┬───────────────────────────┐
│ 部署方式 │ 支持特性 │ 适用场景 │
├───────────────────┼────────────────┼───────────────────────────┤
│ Vercel(官方) │ 全功能 │ 最省心,推荐中小团队 │
│ Node.js Server │ 全功能 │ 自有服务器/VPS │
│ Docker 容器 │ 全功能 │ K8s/云原生部署 │
│ 静态导出 │ 有限(无 SSR) │ 纯静态站点、CDN 托管 │
│ 适配器 │ 因平台而异 │ Cloudflare、Netlify 等 │
└───────────────────┴────────────────┴───────────────────────────┘
Node.js 部署
npm run build
npm run start # 监听 3000 端口
Docker 部署(推荐生产)
# Dockerfile
FROM node:20-alpine AS base
# 安装依赖
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
# 构建
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# 运行(精简镜像)
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
// next.config.ts — 启用 standalone 输出
const nextConfig = {
output: 'standalone',
}
静态导出
// next.config.ts
const nextConfig = {
output: 'export', // 生成纯静态文件
}
npm run build # 生成 /out 目录
# 部署到 Nginx、S3、GitHub Pages 等任意静态托管
多实例部署注意事项
# 多实例需要统一 Server Action 加密密钥
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=<base64-encoded-32-byte-key>
# 版本漂移保护
DEPLOYMENT_VERSION=v1.2.3
// next.config.ts
const nextConfig = {
deploymentId: process.env.DEPLOYMENT_VERSION,
// 共享缓存配置
cacheHandler: require.resolve('./cache-handler.js'),
cacheMaxMemorySize: 0,
}
十八、测试{#testing}
测试类型选择
┌──────────────────┬──────────────────────────────────────────────────┐
│ 测试类型 │ 工具推荐 │ 适用范围 │
├──────────────────┼────────────────────┼────────────────────────────┤
│ 单元测试 │ Jest / Vitest │ 函数、Hooks、工具函数 │
│ 组件测试 │ Jest + RTL │ React 组件渲染与交互 │
│ 端到端测试 │ Playwright / Cypress │ 完整用户流程 │
│ 快照测试 │ Jest │ UI 回归检测 │
└──────────────────┴────────────────────┴────────────────────────────┘
注意:async Server Components 目前主流测试工具支持有限,推荐用 E2E 测试覆盖。
Vitest 单元测试示例
pnpm add -D vitest @vitejs/plugin-react
// lib/utils.test.ts
import { describe, it, expect } from 'vitest'
import { formatDate, slugify } from './utils'
describe('formatDate', () => {
it('应该正确格式化日期', () => {
expect(formatDate(new Date('2024-01-15'))).toBe('2024年01月15日')
})
})
describe('slugify', () => {
it('应该将标题转为 slug', () => {
expect(slugify('Hello World')).toBe('hello-world')
})
})
Playwright E2E 测试示例
pnpm add -D @playwright/test
// tests/auth.spec.ts
import { test, expect } from '@playwright/test'
test('用户可以正常登录', async ({ page }) => {
await page.goto('/login')
await page.fill('input[name="email"]', 'test@example.com')
await page.fill('input[name="password"]', 'password123')
await page.click('button[type="submit"]')
await expect(page).toHaveURL('/dashboard')
await expect(page.locator('h1')).toContainText('欢迎')
})
test('未登录用户被重定向到登录页', async ({ page }) => {
await page.goto('/dashboard')
await expect(page).toHaveURL('/login')
})
十九、实战示例:从简单到高级{#examples}
🟢 简单示例:静态博客首页
// app/page.tsx
import Link from 'next/link'
const posts = [
{ slug: 'nextjs-intro', title: 'Next.js 入门指南', date: '2024-01-01' },
{ slug: 'react-hooks', title: 'React Hooks 深入解析', date: '2024-01-15' },
]
export default function HomePage() {
return (
<main className="max-w-2xl mx-auto py-16 px-4">
<h1 className="text-4xl font-bold mb-8">我的博客</h1>
<ul className="space-y-4">
{posts.map(post => (
<li key={post.slug} className="border rounded-lg p-4 hover:bg-gray-50">
<Link href={`/blog/${post.slug}`}>
<h2 className="text-xl font-semibold text-blue-600">{post.title}</h2>
<p className="text-gray-500 text-sm mt-1">{post.date}</p>
</Link>
</li>
))}
</ul>
</main>
)
}
🟡 进阶示例:带缓存的动态博客列表
// app/blog/page.tsx
import { Suspense } from 'react'
import { cacheLife } from 'next/cache'
import Link from 'next/link'
async function BlogList() {
'use cache'
cacheLife('hours')
const res = await fetch('https://api.vercel.app/blog')
const posts: { id: number; title: string; slug: string }[] = await res.json()
return (
<ul className="space-y-4">
{posts.map(post => (
<li key={post.id} className="border rounded-lg p-4">
<Link href={`/blog/${post.slug}`} className="text-blue-600 hover:underline">
{post.title}
</Link>
</li>
))}
</ul>
)
}
function BlogSkeleton() {
return (
<ul className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<li key={i} className="border rounded-lg p-4 animate-pulse">
<div className="h-5 bg-gray-200 rounded w-3/4" />
</li>
))}
</ul>
)
}
export default function BlogPage() {
return (
<main className="max-w-2xl mx-auto py-16 px-4">
<h1 className="text-4xl font-bold mb-8">博客文章</h1>
<Suspense fallback={<BlogSkeleton />}>
<BlogList />
</Suspense>
</main>
)
}
🟡 进阶示例:全功能 Todo 应用(Server Actions + 乐观更新)
// app/todos/page.tsx
import { addTodo, deleteTodo, toggleTodo } from './actions'
import { getTodos } from '@/lib/data'
export default async function TodoPage() {
const todos = await getTodos()
return (
<main className="max-w-md mx-auto py-16 px-4">
<h1 className="text-3xl font-bold mb-6">待办事项</h1>
{/* 添加表单 */}
<form action={addTodo} className="flex gap-2 mb-6">
<input
name="title"
placeholder="新建待办..."
className="flex-1 border rounded px-3 py-2"
required
/>
<button
type="submit"
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
添加
</button>
</form>
{/* 列表 */}
<ul className="space-y-2">
{todos.map(todo => (
<li key={todo.id} className="flex items-center gap-3 p-3 border rounded">
<form action={toggleTodo}>
<input type="hidden" name="id" value={todo.id} />
<button type="submit">
<span className={todo.done ? 'text-green-500' : 'text-gray-400'}>
{todo.done ? '✅' : '⬜'}
</span>
</button>
</form>
<span className={`flex-1 ${todo.done ? 'line-through text-gray-400' : ''}`}>
{todo.title}
</span>
<form action={deleteTodo}>
<input type="hidden" name="id" value={todo.id} />
<button type="submit" className="text-red-400 hover:text-red-600">
🗑️
</button>
</form>
</li>
))}
</ul>
</main>
)
}
// app/todos/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function addTodo(formData: FormData) {
const title = formData.get('title') as string
await db.todo.create({ data: { title } })
revalidatePath('/todos')
}
export async function toggleTodo(formData: FormData) {
const id = formData.get('id') as string
const todo = await db.todo.findUnique({ where: { id } })
await db.todo.update({ where: { id }, data: { done: !todo?.done } })
revalidatePath('/todos')
}
export async function deleteTodo(formData: FormData) {
const id = formData.get('id') as string
await db.todo.delete({ where: { id } })
revalidatePath('/todos')
}
🔴 高级示例:电商产品页(PPR + 个性化推荐)
这个示例展示了如何在同一页面中混合使用静态内容、缓存内容和动态内容:
// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { cacheLife, cacheTag } from 'next/cache'
import { cookies } from 'next/headers'
import Image from 'next/image'
import AddToCartButton from '@/components/add-to-cart-button'
// 产品数据(缓存1天,按产品ID标记)
async function ProductInfo({ id }: { id: string }) {
'use cache'
cacheLife('days')
cacheTag(`product-${id}`)
const product = await db.product.findUnique({ where: { id } })
if (!product) return null
return (
<div className="grid md:grid-cols-2 gap-8">
<div className="relative h-96">
<Image
src={product.imageUrl}
alt={product.name}
fill
className="object-cover rounded-lg"
priority
/>
</div>
<div>
<h1 className="text-3xl font-bold">{product.name}</h1>
<p className="text-2xl text-green-600 mt-2">¥{product.price}</p>
<p className="text-gray-600 mt-4">{product.description}</p>
<AddToCartButton productId={id} />
</div>
</div>
)
}
// 个性化推荐(基于用户 Cookie,请求时流式渲染)
async function PersonalizedRecommendations({ productId }: { productId: string }) {
const userId = (await cookies()).get('userId')?.value
const recommended = await db.product.findMany({
where: {
id: { not: productId },
...(userId ? { viewedBy: { some: { userId } } } : {}),
},
take: 4,
})
return (
<section>
<h2 className="text-xl font-semibold mb-4">
{userId ? '为你推荐' : '热门商品'}
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{recommended.map(product => (
<a key={product.id} href={`/products/${product.id}`} className="border rounded p-3">
<p className="font-medium text-sm">{product.name}</p>
<p className="text-green-600">¥{product.price}</p>
</a>
))}
</div>
</section>
)
}
// 用户购物车状态(高度个性化,请求时渲染)
async function CartStatus() {
const userId = (await cookies()).get('userId')?.value
if (!userId) return null
const cartCount = await db.cartItem.count({ where: { userId } })
return (
<div className="fixed top-4 right-4 bg-blue-500 text-white rounded-full px-3 py-1">
购物车 ({cartCount})
</div>
)
}
export default function ProductPage({
params,
}: { params: Promise<{ id: string }> }) {
// 注意:这是一个非 async 的 Server Component 包装器
// 它本身被预渲染为静态 Shell
return (
<main className="max-w-6xl mx-auto py-16 px-4">
{/* 产品信息:缓存,纳入静态 Shell */}
<Suspense fallback={<ProductSkeleton />}>
<ProductInfoLoader params={params} />
</Suspense>
<div className="mt-16">
{/* 个性化推荐:动态,请求时流式渲染 */}
<Suspense fallback={<RecommendationSkeleton />}>
<RecommendationsLoader params={params} />
</Suspense>
</div>
{/* 购物车状态:高度动态 */}
<Suspense>
<CartStatus />
</Suspense>
</main>
)
}
async function ProductInfoLoader({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return <ProductInfo id={id} />
}
async function RecommendationsLoader({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return <PersonalizedRecommendations productId={id} />
}
// 骨架屏组件
function ProductSkeleton() {
return (
<div className="grid md:grid-cols-2 gap-8 animate-pulse">
<div className="h-96 bg-gray-200 rounded-lg" />
<div className="space-y-4">
<div className="h-8 bg-gray-200 rounded w-3/4" />
<div className="h-6 bg-gray-200 rounded w-1/4" />
<div className="h-32 bg-gray-200 rounded" />
</div>
</div>
)
}
function RecommendationSkeleton() {
return (
<div className="grid grid-cols-4 gap-4 animate-pulse">
{[1,2,3,4].map(i => (
<div key={i} className="h-32 bg-gray-200 rounded" />
))}
</div>
)
}
// components/add-to-cart-button.tsx
'use client'
import { useState, useTransition } from 'react'
import { addToCart } from '@/app/actions'
export default function AddToCartButton({ productId }: { productId: string }) {
const [isPending, startTransition] = useTransition()
const [added, setAdded] = useState(false)
const handleClick = () => {
startTransition(async () => {
await addToCart(productId)
setAdded(true)
setTimeout(() => setAdded(false), 2000)
})
}
return (
<button
onClick={handleClick}
disabled={isPending}
className={`mt-6 w-full py-3 rounded-lg font-semibold transition-all ${
added
? 'bg-green-500 text-white'
: 'bg-blue-500 text-white hover:bg-blue-600'
} disabled:opacity-50`}
>
{isPending ? '添加中...' : added ? '✅ 已添加到购物车' : '加入购物车'}
</button>
)
}
🔴 高级示例:多租户 SaaS 应用架构
多租户路由架构(基于子域名):
用户访问 tenant-a.myapp.com
│
▼
proxy.ts
│ 读取子域名
▼
解析 tenantId
│
▼
注入到 headers
│
▼
app/layout.tsx
读取 tenantId
加载租户配置
│
▼
渲染对应主题/数据
// proxy.ts(多租户路由)
import { NextRequest, NextResponse } from 'next/server'
export default function proxy(req: NextRequest) {
const hostname = req.headers.get('host') ?? ''
const subdomain = hostname.split('.')[0]
// 排除主域名和特殊子域
if (['www', 'app', 'api'].includes(subdomain)) {
return NextResponse.next()
}
// 将 tenantId 注入 header,供下游 Server Component 使用
const requestHeaders = new Headers(req.headers)
requestHeaders.set('x-tenant-id', subdomain)
return NextResponse.next({ request: { headers: requestHeaders } })
}
export const config = {
matcher: ['/((?!_next|api|favicon.ico).*)'],
}
// app/layout.tsx(读取租户配置)
import { headers } from 'next/headers'
import { getTenantConfig } from '@/lib/tenants'
export default async function RootLayout({
children,
}: { children: React.ReactNode }) {
const tenantId = (await headers()).get('x-tenant-id') ?? 'default'
const tenant = await getTenantConfig(tenantId)
return (
<html lang="zh">
<body
style={{
'--primary-color': tenant.primaryColor,
'--logo-url': `url(${tenant.logoUrl})`,
} as React.CSSProperties}
>
<header className="border-b px-6 py-4">
<div
className="h-8 w-32 bg-contain bg-no-repeat"
style={{ backgroundImage: `var(--logo-url)` }}
/>
<h1 className="text-xl font-bold" style={{ color: 'var(--primary-color)' }}>
{tenant.name}
</h1>
</header>
<main className="px-6 py-8">{children}</main>
</body>
</html>
)
}
二十、综合知识地图
通读全文后,用一张全景图来串联所有核心概念:
┌─────────────────────────────────────────────────────────────────────┐
│ Next.js App Router 全景架构 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ Browser │ │ CDN │ │ Proxy │ │ Next.js Server │ │
│ │ │ │ │ │(proxy.ts)│ │ │ │
│ │ React │ │ 静态资源 │ │ 路由保护 │ │ ┌──────────────┐ │ │
│ │ Client │◄─┤ 缓存页面 │◄─┤ 重定向 │◄─┤ │ App Router │ │ │
│ │ 水合交互 │ │ HTML/JS │ │ rewrite │ │ │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ │ │ layout.tsx │ │ │
│ ▲ │ │ page.tsx │ │ │
│ │ RSC Payload │ │ loading.tsx │ │ │
│ │ + HTML │ │ error.tsx │ │ │
│ │ │ └──────────────┘ │ │
│ │ │ │ │
│ ┌────┴──────────────────────────────────┐ │ ┌──────────────┐ │ │
│ │ 渲染策略 │ │ │ 数据层 │ │ │
│ │ │ │ │ │ │ │
│ │ 静态(SSG) ─── 构建时预渲染 │ │ │ fetch() │ │ │
│ │ 动态(SSR) ─── 请求时渲染 │ │ │ ORM/DB │ │ │
│ │ ISR ─── 定期重新验证 │ │ │ Server Fn │ │ │
│ │ PPR ─── 静态Shell+动态流 │ │ │ Route Handler│ │ │
│ └───────────────────────────────────────┘ │ └──────────────┘ │ │
│ │ │ │
│ ┌───────────────────────────────────────┐ │ ┌──────────────┐ │ │
│ │ 缓存层次 │ │ │ 缓存系统 │ │ │
│ │ │ │ │ │ │ │
│ │ React Cache ─── 单次请求去重 │ │ │ 'use cache' │ │ │
│ │ use cache ─── 跨请求持久化 │ │ │ cacheLife() │ │ │
│ │ CDN Cache ─── 全球边缘缓存 │ │ │ cacheTag() │ │ │
│ │ 浏览器缓存 ─── 资源本地缓存 │ │ │ revalidateTag│ │ │
│ └───────────────────────────────────────┘ │ └──────────────┘ │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
二十一、常见问题与最佳实践
Q1:什么时候用 Server Component,什么时候用 Client Component?
判断流程:
该组件需要...
├── useState / useEffect / 事件处理?
│ └── ✅ Client Component ('use client')
│
├── 直接读取数据库/API Key/密钥?
│ └── ✅ Server Component(默认)
│
├── 只是展示静态内容?
│ └── ✅ Server Component(减少 JS bundle)
│
└── 既有状态又有数据?
└── 拆分:Server Component 获取数据 → props → Client Component 处理交互
Q2:revalidatePath vs revalidateTag vs updateTag
┌─────────────────┬──────────────────────────────────────────────┐
│ API │ 使用场景 │
├─────────────────┼──────────────────────────────────────────────┤
│ revalidatePath │ 重验证某个路径的所有缓存(粗粒度) │
│ revalidateTag │ 重验证带有特定 tag 的缓存(细粒度,后台刷新) │
│ updateTag │ 立即使缓存失效(读己写场景,仅 Server Action) │
└─────────────────┴──────────────────────────────────────────────┘
推荐优先级:updateTag > revalidateTag > revalidatePath
Q3:如何避免客户端 JS 包体积过大?
- 将静态 UI 保持为 Server Component,只对有交互的最小单元加
'use client' - 使用
@next/bundle-analyzer分析包体积 - 动态导入(懒加载) 非首屏组件:
import dynamic from 'next/dynamic'
// 仅在客户端加载(适合大型图表、编辑器等)
const RichEditor = dynamic(() => import('@/components/rich-editor'), {
ssr: false,
loading: () => <div>编辑器加载中...</div>,
})
Q4:数据获取应该放在哪一层?
推荐原则:在"最近的数据使用者"处获取
✅ 推荐:
BlogPost 组件(Server Component)
└── 直接 fetch 自己需要的 post 数据
❌ 不推荐(prop drilling):
Page
└── fetch 所有数据
└── 传给 BlogPost → 传给 Comment → 传给 Author
Q5:如何处理表单验证?
// 服务端验证(Zod,必须做)
import * as z from 'zod'
const schema = z.object({
email: z.email('请输入有效邮箱'),
password: z.string().min(8, '密码至少8位'),
})
export async function login(prevState: any, formData: FormData) {
'use server'
const result = schema.safeParse({
email: formData.get('email'),
password: formData.get('password'),
})
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
}
}
// 执行登录逻辑...
}
二十二、Next.js 版本演进速查
v13 → App Router 引入(实验性)
v14 → App Router 稳定,Server Actions 正式支持
v15 → React 19 支持,Turbopack 默认,async cookies/headers
v16 → Cache Components(use cache),PPR 默认启用,proxy.ts 取代 middleware.ts
总结
Next.js 从一个简单的 SSR 框架,已经演进成为现代全栈 Web 开发的最佳选择之一。理解其核心理念的关键在于:
1. 服务器优先:默认在服务器渲染,只把必要的交互逻辑下沉到客户端,从而减少 JS 体积、提升 FCP。
2. 组合优于层级:Server Component 可以持有 Client Component(通过 props/children),这让我们可以精细地控制每一块 UI 的运行环境。
3. 缓存即架构:use cache、cacheLife、cacheTag 构成了声明式的缓存体系——你描述"这些数据应该缓存多久",框架来决定何时从缓存读、何时重新生成。
4. 渐进增强:从静态导出到完整 SSR,从简单 fetch 到 PPR,Next.js 的能力是一个连续的谱系,可以按需取用。
希望这篇指南能帮助大家系统地建立起 Next.js 的知识体系。建议的学习路径是:先跑通安装 → 理解文件路由 → 掌握 Server/Client Component 边界 → 学会数据获取与缓存 → 再深入认证、部署等进阶主题。配合官方文档 nextjs.org/docs 与 GitHub 仓库 vercel/next.js,会对这套体系越来越得心应手。