Next.js App Router 核心实战:路由、数据获取与Server Actions
写这篇整理的时候我刚把一个小项目从Pages Router迁到App Router中间又踩了几个坑顺手把这一年多来常用的Next.js用法重新梳理了一遍。这篇东西不是官方文档的复述也不是从零开始的入门教程而是把日常开发里真正高频、能落地、容易忽略的用法和反直觉的细节整理出来。内容偏向App Router体系也就是现在Next.js的默认架构覆盖路由、数据获取、Server Actions、SEO和部署这些核心场景适合两种人看刚接触App Router想快速建立正确心智模型的以及已经用了一阵但某些细节还没细究的。文中代码都基于近期稳定版本跑过验证过可以直接抄。1. 项目初始化与工程结构1.1 创建项目时值得使用的参数初始化用官方脚手架就行没必要手动搭Webpack或Turbopack的配置npx create-next-applatest my-app --ts --tailwind --app --src-dir --import-alias /*这里把每个flag背后对应什么说明白--app启用App Router目录结构实际生成app/而不是pages/。即便你不确定要不要用App Router现阶段也建议直接选App RouterPages Router虽然还在维护但新功能基本只往App Router上加。--tsTypeScript不用纠结直接上。--src-dir所有源码挪进src/目录和根目录的配置文件分开PR时能少很多冲突。--import-alias /*绝对路径别名避免到处写../../../。装完依赖跑npm run dev基础环境就位。要是公司内网有registry代理记得先配好.npmrc。1.2 关键目录和文件约定App Router的目录约定比Pages Router多一套规则但核心只需要关注几个src/app/ ├── layout.tsx # 根布局全局唯一入口 ├── page.tsx # 首页路由 ├── globals.css ├── api/ # Route Handlers │ └── hello/route.ts ├── (marketing)/ # 路由分组 │ └── about/page.tsx ├── blog/ │ ├── [slug]/ │ │ ├── page.tsx # 动态路由 │ │ └── generateStaticParams.ts │ ├── layout.tsx # blog 局部布局 │ └── page.tsx └── not-found.tsx几个重要文件的作用和注意点layout.tsx是嵌套的app/layout.tsx包全站app/blog/layout.tsx只包blog下面的路由。布局组件默认是服务端组件里面放状态或者用useEffect会直接报错这个坑后面细说。page.tsx是路由的必需文件没有它访问路径会404。loading.tsx和error.tsx是可选文件但强烈建议每个有数据请求的页面都配一份作用是在路由切换时显示加载态和兜底错误。route.ts处理API请求文件内可以定义GET、POST、PUT、DELETE等导出函数。(marketing)这种圆括号目录叫路由分组不产生URL路径纯粹用于组织文件。注意分组之间可能出现渲染边界共享布局时要小心。public/目录放静态资源可以直接通过/favicon.ico这样的路径访问。next.config.ts里能配images.remotePatterns、redirects、headers等内容常见的图片域名白名单就在那儿配。2. 路由系统的核心写法与文件约定2.1 布局嵌套与template的取舍布局是App Router最值钱的特性之一页面切换时布局默认不重渲染子路由的page.tsx切换时layout.tsx的组件状态会保留。这对想保持导航栏状态、滚动位置、弹窗状态的场景非常有用。template.tsx是个容易被忽略的兄弟文件// src/app/blog/template.tsx use client; export default function Template({ children }: { children: React.ReactNode }) { useEffect(() { // 每次路由切换都会执行 console.log(路由变化); }, []); return div{children}/div; }template.tsx和layout.tsx的区别就一句话layout只创建一次template在每次子路由切换时重新创建。需要埋点、重置状态、刷新动画的场景用template需要持久状态的场景用layout。2.2 动态路由与静态参数生成动态路由用方括号目录表示app/blog/[slug]/page.tsx可以匹配/blog/hello-world这样的路径// src/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(); } return article{post.title}/article; }这里一个版本变迁的关键点在新版Next.js中params和searchParams都变成了Promise必须await。很多人从旧版迁移时在这里踩坑——TypeScript类型报错就是因为没有await。配合静态导出和增量静态再生成需要提前列出所有可能的动态路径// src/app/blog/[slug]/generateStaticParams.ts export async function generateStaticParams() { const posts await getAllPosts(); return posts.map((post) ({ slug: post.slug })); }generateStaticParams返回的数组会在构建时生成对应的静态页面适合文章数量不多的博客。如果内容频繁变化就不推荐把所有页都静态化而是用revalidate或动态渲染兜底。另外catch-all路由用[...slug]表示匹配一层或多层路径比如/docs/a/b/c。可选的全匹配路由用[[...slug]]连根路径也能匹配上。这两种方式适合文档站、分类层级多的场景。2.3 加载态、错误态与404的层级分布每个路由段都可以放三个特殊文件loading.tsx基于Suspense的加载界面只在服务端数据流式传输时显示。error.tsx错误边界必须是客户端组件要带use client因为React错误边界本身依赖客户端机制。global-error.tsx根布局级别的错误边界只有它才能捕获根layout.tsx里的错误。not-found.tsx触发notFound()或URL不匹配时显示。典型的error.tsx写法// src/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 h2加载文章时出了点问题/h2 button onClick{reset}重试/button /div ); }reset会重新渲染同一段路由的内容常用于接口临时故障时的恢复按钮。记住error.tsx和loading.tsx是逐层匹配的放在app/blog/error.tsx只影响blog下的所有子路由不会冒泡到上层。2.4 路由拦截与并行路由两个进阶路由能力在复杂页面里很有用拦截路由目录名以(.)开头如app/photos/(.)photo/[id]/page.tsx可以从列表页预取并“拦截”详情页常见于图片画廊点击放大的场景URL不变但内容变成全屏弹层。并行路由目录以开头如app/analytics/page.tsx与app/chat/page.tsx可以同时渲染多个独立区块适合后台管理面板、多栏dashboard。并行路由配合useSelectedSegment做条件渲染能解决“同一路径下不同slot显示不同内容”的需求。并行路由也有个坑硬刷新或直接访问URL时如果某个slot没有对应的page.tsx会直接白屏。通常要在layout.tsx的slot位置用default.tsx兜底。3. 数据获取模式与缓存策略3.1 服务端组件的请求写法App Router里服务端组件可以直接async function然后await不需要useEffect也不需要状态管理// src/app/dashboard/page.tsx export default async function DashboardPage() { const res await fetch(https://api.example.com/dashboard, { next: { revalidate: 60 }, }); const data await res.json(); return DashboardView data{data} /; }注意这里fetch扩展了几个Next.js专属配置项next.revalidate页面以60秒的间隔做增量静态再生成数据更新不会超过1分钟延迟同时保持静态页的加载速度。cache: no-store完全跳过缓存每次请求都走网络。适合实时性要求高的场景比如库存、余额代价是响应时间提升。cache: force-cache强制走缓存适合长期不变的内容。理解这套逻辑的关键Next.js默认在构建时静态化所有页面但只要你用了cookies()、headers()或者取消缓存的数据请求页面就自动切换为动态渲染。新版还可以通过export const dynamic force-dynamic在页面文件里显式声明动态渲染不依赖某个具体请求。如果不喜欢原生fetch也可以封装自己的数据层比如getUser()内部用数据库客户端// src/lib/db.ts import { cache } from react; export const getUser cache(async (id: string) { const user await db.query(SELECT * FROM users WHERE id $1, [id]); return user; });cache函数来自React保证同一请求周期内相同入参只执行一次真正的查询避免同一个服务端组件树的多个部分反复请求数据库。3.2 服务端组件向客户端组件传递数据服务端拿到的数据可以以串行化的方式直接传给客户端组件。客户端组件文件需要use client标记// src/app/appointments/page.tsx (服务端组件) import { ClientCalendar } from ./client-calendar; export default async function AppointmentsPage() { const slots await fetchSlots(); return ClientCalendar slots{slots} /; }// src/app/appointments/client-calendar.tsx (客户端组件) use client; export function ClientCalendar({ slots }: { slots: Slot[] }) { const [selected, setSelected] useStatestring | null(null); // 交互逻辑... }这么做的核心思路是数据获取、权限校验、SEO相关的逻辑留在服务端事件处理、受控输入、浏览器API相关的交互放客户端。两端组件可以混合嵌套但数据传递必须是可序列化的纯对象、数组、原始类型函数、Date实例、类实例不能直接传。3.3 客户端数据请求的兜底方案也有必须完全在客户端拿数据的时候比如用户登录后才能看到的内容。这种场景可以直接用fetch加useEffect但要注意竞态问题use client; import { useEffect, useState } from react; export function Profile() { const [user, setUser] useStateUser | null(null); const [loading, setLoading] useState(true); const [error, setError] useStatestring | null(null); useEffect(() { let cancelled false; fetch(/api/me) .then((res) res.json()) .then((data) { if (!cancelled) setUser(data); }) .catch(() { if (!cancelled) setError(获取失败); }) .finally(() { if (!cancelled) setLoading(false); }); return () { cancelled true; }; }, []); if (loading) return p加载中.../p; if (error) return p{error}/p; if (!user) return p未登录/p; return p你好{user.name}/p; }这里用cancelled标记防止组件卸载后异步回调触发setState是个零依赖但很实用的防竞态技巧。数据需求复杂的场景轮询、缓存、分页、请求去重建议直接用SWR或TanStack Query没必要自己全造一遍轮子。4. Server Actions与表单处理4.1 Server Actions的基本用法Server Actions让表单提交不再需要单独的API接口。在文件顶部写use server导出的函数就能直接传给form action{...}// src/app/todos/actions.ts use server; import { revalidatePath } from next/cache; import { redirect } from next/navigation; export async function addTodo(formData: FormData) { const title formData.get(title) as string; if (!title || title.trim().length 0) { return { error: 标题不能为空 }; } await db.insert({ title: title.trim() }); revalidatePath(/todos); redirect(/todos); }// src/app/todos/page.tsx import { addTodo } from ./actions; export default function TodosPage() { return ( form action{addTodo} input nametitle placeholder输入待办 / button typesubmit新增/button /form ); }这套机制把网络往返、序列化、状态更新都隐藏了开发体验接近传统服务端框架比如PHP或Rails的表单提交模型。revalidatePath让相关页面重新拉取数据redirect在成功处理后跳转。4.2 表单状态与乐观更新表单提交过程中的Pending状态和乐观更新Next.js提供了专门的Hooksuse client; import { useFormStatus } from react-dom; import { useOptimistic } from react; import { addTodo } from ./actions; function SubmitButton() { const { pending } useFormStatus(); return ( button typesubmit disabled{pending} {pending ? 保存中... : 保存} /button ); }useFormStatus要求子组件在form内部才能拿到pending状态这个限制初看很怪实际是为了配合表单的时序渲染。useOptimistic则适合做点赞、发送消息这类立刻回显的交互use client; import { useOptimistic } from react; export function LikeButton({ likes }: { likes: number }) { const [optimisticLikes, addOptimisticLike] useOptimistic( likes, (state, newValue: number) state newValue ); return ( button onClick{async () { addOptimisticLike(1); await updateLikes(); // 实际请求 }} 赞 {optimisticLikes} /button ); }4.3 Server Actions的注意事项几个容易踩的点传入action的函数不能是箭头函数因为箭头函数没有this绑定在服务端框架的表单处理上下文里行为不符合预期。用普通函数声明即可。所有入参和返回值必须能被序列化。传复杂对象比如Map、循环引用对象会直接报错。Server Actions本质上是POST请求。页面里如果嵌了第三方脚本比如分析工具要注意请求路径/_next/data和动作请求的区分别在中间层误拦截。权限校验务必在Server Action内部再做一次不能只靠前端隐不隐藏按钮。任何公开访问的Action都有人能绕过UI直接调用。5. 元数据管理与SEO优化实践5.1 静态与动态元数据App Router把head的管理内置了直接用export const metadata导出对象// src/app/page.tsx import type { Metadata } from next; export const metadata: Metadata { title: 首页 - 我的站点, description: 一个用Next.js构建的示例站点, openGraph: { title: 首页 - 我的站点, description: 一个用Next.js构建的示例站点, url: https://example.com, siteName: 我的站点, type: website, }, twitter: { card: summary_large_image, }, };动态页面需要根据参数或请求信息生成元数据用generateMetadata函数// src/app/blog/[slug]/page.tsx import type { Metadata } from next; export async function generateMetadata({ params, }: { params: Promise{ slug: string }; }): PromiseMetadata { const { slug } await params; const post await getPost(slug); if (!post) { return { title: 文章不存在 }; } return { title: ${post.title} - 我的博客, description: post.excerpt, alternates: { canonical: /blog/${slug}, }, }; }generateMetadata可以拿到params、searchParams甚至能读headers()来适配不同设备的元数据。常用场景包括根据文章生成OG图片、根据页码生成分页标签、根据登录状态生成不同描述。5.2 JSON-LD与结构化数据搜索引擎的富媒体结果依赖结构化数据Next.js里直接在组件里输出script typeapplication/ldjson// src/app/blog/[slug]/page.tsx export default async function BlogPost({ params }: { params: Promise{ slug: string } }) { const { slug } await params; const post await getPost(slug); const jsonLd { context: https://schema.org, type: BlogPosting, headline: post.title, datePublished: post.date, author: { type: Person, name: post.author }, }; return ( script typeapplication/ldjson dangerouslySetInnerHTML{{ __html: JSON.stringify(jsonLd) }} / article{post.title}/article / ); }这里别用React的script内联方式传对象直接把对象传给dangerouslySetInnerHTML或script标签的childrenReact会帮你序列化但要确保JSON没有非法转义。robots.txt和sitemap.xml直接放app目录下app/robots.ts和app/sitemap.ts能动态生成比每次构建人肉更新文件方便得多。6. 静态资源与性能优化配置6.1 图片组件与域名白名单Next.js内置了图片优化能力图片组件会自动做响应式尺寸、格式转换比如输出WebP/AVIF和懒加载。常规用法import Image from next/image; export default function Avatar({ src, alt }: { src: string; alt: string }) { return Image src{src} alt{alt} width{256} height{256} /; }如果图片来自外部域名必须配置白名单// next.config.ts const nextConfig { images: { remotePatterns: [ { protocol: https, hostname: images.example.com }, { protocol: https, hostname: avatars.githubusercontent.com }, ], }, }; export default nextConfig;没配白名单会有两种表现开发环境直接报错提示生产环境返回500或占位失败。远程图片最好同时配置width和height避免布局偏移也可以选fill属性配合position: relative的父容器做自适应铺满。6.2 字体优化字体加载是影响LCP的重要指标Next.js除了自动处理自托管字体也方便地接入Google Fonts// src/app/layout.tsx import { Inter } from next/font/google; const inter Inter({ subsets: [latin] }); export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( html langzh-CN body className{inter.className}{children}/body /html ); }next/font会在构建时下载字体文件并自动加上font-display: swap避免FOUT页面加载时字体闪烁。它还会自动生成合适的preload标记。自托管字体文件也可以放进项目里通过localFont加载CSP严格的项目优先用自托管。6.3 静态导出与部署形态选择不同场景选不同的部署形态这决定了很多配置的写法部署形态配置方式适用场景纯静态导出output: exportimages用unoptimized纯前端展示、GitHub Pages、CDN增量静态再生默认配置配合revalidate博客、文档站、内容更新不频繁的站点服务端渲染默认配置配合force-dynamic或cookies()需要实时数据、用户个性化完全动态所有请求不做静态化后台、电商库存、协同编辑如果使用了output: export需要把动态路由的所有页面用generateStaticParams列出来而且Server Actions、cookies()、rewrites这些依赖Node.js运行时的能力都没法用。容器部署时用next start跑生产服务可以同时拿到增量静态再生成和流式渲染的全部能力。7. 常见问题与排查技巧实录这段时间反复被问到的问题很多有共性整理成速查表问题现象根本原因解决方案params类型报错或取不到值新版里params是Promiseconst { slug } await params;服务端组件里用useState报错服务端组件不支持Hooks状态拆分成客户端子组件加use clienterror.tsx不生效或报错error.tsx没写use client文件顶部加use client改了环境变量不生效只读process.envNEXT_PUBLIC_前缀的变量才打到浏览器自定义变量加NEXT_PUBLIC_前缀并重启dev server外部图片不显示next.config没配remotePatterns按域名加白名单动态路由构建时报404静态导出时没列generateStaticParams列出所有可能的参数组合表单提交后页面数据不更新没有触发重新获取在Server Action里revalidatePath构建内存溢出静态生成页数太多或组件内存占用大增加Node内存NODE_OPTIONS--max_old_space_size4096或改部分路由为动态渲染7.1 Next.js版本升级后页面报错版本升级是重灾区尤其是跨大版本升级常见症状params类型错误、next/image的layout属性失效、pages/api里某些类型改名。遇到的每个报错先查官方Upgrade Guide有一个比较省力的排查路径先把next.config的experimental标记全删掉因为旧版实验功能在新版里经常被移除或改名然后逐项跑next build看报错输出按顺序解决。构建日志比运行时日志信息更完整能定位到具体文件。7.2 开发环境热更新失效或者很慢如果你的项目变得很慢检查是不是把大文件放在public/下、或频繁读取文件系统导致监听器满载。next dev默认用Turbopack时遇到感觉卡顿的情况可以先关掉Turbopack试试Webpack模式确认是不是Turbopack对某些插件支持还不完善。官方现在推荐Turbopack但某些老项目依赖的Webpack插件比如自定义Babel配置还没完全兼容。7.3 客户端组件和服务端组件的边界这个边界问题几乎每个人都会碰到。最简单的原则文件顶部的use client声明之后这个模块以及它所有引入的模块都会变成客户端包的一部分。如果一个没有use client的组件引用了一个带use client的组件那个带声明的组件仍然按客户端组件处理但中途的“边界”必须在客户端打开否则React会报流水线水合错误。实操上遇到“you are importing a component that needs use client”这类提示时在引入方还是被引入方加标记不是猜的——谁用了浏览器API、谁用了事件处理、谁用了状态谁就必须在边界的客户端一侧。最好的做法是让最内层交互组件自带use client外层服务端组件通过children传入而不是反向嵌套。写在最后的一个私货我个人的体会是Next.js的真正价值不在“服务端渲染”这个标签本身而在于它把静态生成、增量再验证、缓存、流式渲染、Server Actions这些看似独立的能力整合进了一套心智模型。你不需要在建项目时就想清楚每个页面是什么渲染模式而是可以先写成服务端组件再按需调整缓存策略最后用generateStaticParams把高频页面静态化。这种把事情变简单的能力比任何单点功能都重要。如果想真正掌握这套体系建议不要只跑官方demo找一个小而完整的真实项目比如个人博客、团队知识库、小工具站从零重写一遍把文章里提到的每个文件都实际用一遍。迁移过程中遇到的那些报错和反直觉细节才是你真正学到东西的地方。后续想深入的话可以继续研究Streaming SSR的节点调度、next/cache的标签失效机制、以及Parallel Routes和Intercepting Routes在复杂界面里的组合用法。