☰
Next.js App Router 实战指南:路由、服务端渲染与数据获取
2026/10/5 4:01:02 网站建设 项目流程

1. 先搞清楚 Next.js 到底是干嘛的,再谈用法

1.1 一个框架把前后端边界重新画了一遍

这几年我一直跟身边做 React 的朋友说,Next.js 最厉害的地方不是"服务端渲染"这几个字,而是它把"前端项目"这个概念彻底重构了。以前我们做 React 应用,标配是:Create React App 起一个纯前端工程,再单独搞一个 Node/Java/PHP 后端,前后端通过接口联调,部署也要分开搞。这种模式本身没什么错,但一旦产品需要 SEO、首屏速度、复杂的路由嵌套、权限中间件,你就得在工程里额外引入一堆方案,而且每条链路都是独立维护的,时间久了体感非常累。

Next.js 的思路是把 React 组件直接跑在服务端,让组件既是页面、又是接口、还能做数据加工。你在一个app/目录里写下page.tsx,它就是一个路由;你在同一个组件里await数据库查询,它还能在服务端把 HTML 渲染好再返回。对一个中小团队或者个人开发者来说,这意味着你只需要掌握 React 和一点点 Node 知识,就能把"前端页面 + 后端接口 + 部署配置"三个环节压缩成一个工程。这个模式最舒服的地方是心智负担小:不需要再记忆"我这个接口应该跨域怎么处理""我这个鉴权 token 放在哪个请求头",因为很多逻辑可以直接写在服务端,浏览器永远接触不到秘密。

1.2 不是什么项目都适合直接上 Next.js

虽然我推荐大家学,但也不建议无脑替换。如果你的场景是内部管理后台、纯仪表盘、完全没有 SEO 需求的工具型应用,那用 Vite + React + 前端路由反而更轻快,因为这类应用不需要首屏直出,也没有被搜索引擎抓取的压力。反过来,只要你的产品需要面对公网、需要分享链接、需要内容被收录,或者首页打开速度直接影响转化,那 Next.js 的价值就非常明显。

另外要注意的是,Next.js 虽然有服务端能力,但它的运行环境依然是 Node.js 风格的运行时(当然现在也有 Edge Runtime),数据库操作、文件读写、环境变量管理这些事它都能做,只是要遵循框架的约定。你如果真的要在生产环境大规模使用,得想清楚部署平台:自建服务器可以用 Docker 跑 Node 服务,也可以用 Vercel 这种平台直接托管。我的建议是,小项目先别折腾自建,直接用next build && next start跑在生产模式,等流量真上来了再考虑容器化,这样能把学习成本和运维成本降到最低。

2. 初始化项目:版本、脚手架、目录结构一次说清

2.1 create-next-app 的版本差异与常用参数

现在初始化一个 Next.js 项目,官方推荐的方式依然是npx create-next-app@latest。但这里有个值得注意的点:不同大版本(12、13、14、15)初始化的项目结构差异非常大,尤其是 13.4 之后 App Router 进入稳定阶段,新项目默认就是app/目录,而老项目是pages/目录。我见过不少人照着网上老教程敲命令,结果代码放到新版项目里直接报page找不到,其实就是版本约定变了。

具体执行时,我建议把参数一次写明白,避免交互式提问浪费时间。比如下面这条命令:

npx create-next-app@latest my-app --ts --tailwind --eslint --app --src-dir --import-alias "@/*"

拆开解释一下:--ts开启 TypeScript,--tailwind集成 Tailwind CSS,--eslint内置代码检查,--app使用 App Router,--src-dir把源码放到src/文件夹下,--import-alias设置路径别名。如果你不想用 Tailwind,去掉对应的参数即可;如果团队已经习惯 Pages Router,可以把--app换成--no-app。我实测下来,TypeScript 强烈建议开启,哪怕你只是写个小项目,因为 Next.js 的类型提示对路由参数、搜索参数、组件 props 的帮助非常大,能省掉一堆低级错误。

2.2 自动生成的目录里,哪些文件是核心

初始化完成后,你会看到一堆文件,新手最容易犯的错是里面每个文件都想碰一碰。实际需要关注的只有几个:

  • src/app/layout.tsx:根布局,所有页面共享的壳,比如全局导航、全局样式、<html><body>标签都在这。
  • src/app/page.tsx:根路径/对应的首页。
  • next.config.ts或next.config.mjs:框架配置,比如图片域名白名单、rewrites、redirects。
  • src/app/globals.css:全局样式入口。

其他的.next/是构建产物,node_modules/是依赖,public/放静态资源,都不需要手动改。package.json里最常用的脚本是npm run dev(开发模式)、npm run build(生产构建)、npm run start(启动生产服务)。这里有个体感很强的细节:开发模式下每次保存代码都会触发 Fast Refresh,局部更新,体感很快;但生产构建时会做完整的静态优化和代码分割,所以本地看起来好好的代码,一构建就报错的情况并不少见,后面我会专门讲排查方法。

3. App Router 的路由约定与渲染模型

3.1 文件命名即路由:page、layout、loading、error、not-found

App Router 最核心的思维方式是"文件系统路由"。你在app/目录下建一个文件夹,文件夹名字就是 URL 路径的一部分;在文件夹里放一个page.tsx,这个文件就是该路径对应的页面组件。比如:

src/app/ ├── layout.tsx ├── page.tsx → / ├── about/ │ └── page.tsx → /about ├── blog/ │ ├── layout.tsx → /blog 下的子布局 │ ├── page.tsx → /blog │ └── [slug]/ │ └── page.tsx → /blog/hello-world

动态路由用方括号[slug]表示,这一层文件夹对应的路由参数会作为params传给组件。比如src/app/blog/[slug]/page.tsx里的组件签名是export default async function Page({ params }: { params: Promise<{ slug: string }> }),注意新版 Next.js 里params是 Promise,需要await一下,这是 15 之后才有的变化,很多老教程没更新,导致params.slug直接读不到。

除了page,约定文件里还有几个重要的:

文件名作用触发时机
layout.tsx嵌套布局,子页面共享 UI渲染该路由层级时始终存在
loading.tsx加载状态,配合 Suspense页面异步组件挂起时显示
error.tsx错误边界子组件抛错时显示
not-found.tsx404 页面路由匹配不到或主动notFound()时显示

这套约定最大的价值是把"页面状态"拆成了文件级关注点。比如你的详情页要请求数据,接口慢,你不用自己在组件里写 loading 状态管理,建一个loading.tsx就能在等待时渲染骨架屏,而且这个文件只影响对应层级,不会污染其他页面。

3.2 服务端组件 vs 客户端组件,别再用错了

App Router 下所有组件默认是服务端组件(Server Component),这意味着它们默认没有useState、useEffect、onClick这些交互能力。想用交互,必须在文件顶部写"use client",把这个文件标记为客户端组件。这个设计刚出来时被很多人骂,觉得多此一举,但实际用久了会发现它是性能优化的利器:大部分页面信息展示的逻辑(拼字符串、格式化日期、读数据库)都可以留在服务端执行,只有类似按钮点击、输入框监听这种浏览器行为才需要打包成 JS 发给客户端。

举一个非常典型的例子,你的博客文章详情页:

// 服务端组件,直接在服务端读取数据 export default async function PostPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const post = await getPostById(id); return ( <article> <h1>{post.title}</h1> <div dangerouslySetInnerHTML={{ __html: post.content }} /> </article> ); }

上面的组件完全没有交互逻辑,所以服务端渲染完直接吐出 HTML,客户端不需要为此下载任何 JS。只有当你需要在文章下面加一个点赞按钮时,才单独抽一个客户端组件出来:

"use client"; export default function LikeButton({ postId }: { postId: string }) { const [liked, setLiked] = useState(false); return ( <button onClick={() => setLiked(true)}> {liked ? "已点赞" : "点赞"} </button> ); }

这种"大部分服务端、局部客户端"的组合,就是 Next.js 性能比传统 SPA 好的根本原因。你不需要为了一个按钮交互就让整篇文章跑到浏览器里再渲染一遍。

3.3 一个容易踩的坑:客户端组件里引用了服务端模块

这个坑我印象很深。有次我写一个列表页,数据逻辑放在lib/api.ts里,这个文件里用了fs读取本地 JSON 文件。列表组件本身导入了这个文件,而我为了处理筛选交互给列表加了"use client",结果浏览器直接报错,提示fs模块不存在。原因很简单:客户端组件和它引用的所有模块都会被打包发到浏览器,Node 内置模块在浏览器环境里当然没有。

解决方式有三个,按推荐顺序排:

  1. 把数据获取逻辑留在父组件(服务端组件)里,把处理好的数据结构传给客户端子组件。
  2. 如果客户端组件确实需要某些纯函数,把它们单独放到一个没有fs依赖的文件里。
  3. 用 Next.js 的server-only包在服务端模块文件顶部显式声明,让框架在引用错误时直接给出清晰提示:
npm install server-only

然后在lib/api.ts第一行写:

import "server-only";

这样一旦有人把api.ts误引入客户端组件,编译器直接报错,而不是等到浏览器运行才出错,排查成本低很多。

4. 数据获取的四种姿势:选型比写代码更重要

4.1 服务端组件里的异步请求

在 App Router 的服务端组件里,数据获取变成了一个普通的await操作,这可能是 Next.js 最近几个版本里最让我舒服的改动。以前在 Pages Router 里要写getServerSideProps,一个页面配一个导出函数,繁琐且不直观;现在直接在组件函数体里写异步逻辑就行,因为服务端组件本质就是一个 async function。

一个实操中非常推荐的做法是把数据库查询或 API 请求包成函数,放在组件外面调用,比如:

// app/dashboard/page.tsx async function getDashboardData() { const res = await fetch("https://api.example.com/dashboard"); if (!res.ok) { throw new Error("数据获取失败"); } return res.json(); } export default async function DashboardPage() { const data = await getDashboardData(); return <DashboardView data={data} />; }

这里有个细节:fetch在服务端组件里默认会被缓存。Next.js 把fetch做了扩展,默认情况下相同 URL 的请求会被缓存(类似静态生成),直到缓存失效。如果你希望每次访问都拿最新数据,就得写成:

const res = await fetch("https://api.example.com/dashboard", { next: { revalidate: 60 } });

revalidate: 60表示每 60 秒重新验证一次缓存,这种模式就是 ISR(增量静态生成)。如果完全不想缓存,用cache: "no-store"。很多初学者看到页面数据不变,以为是自己代码错了,其实就是因为忘了关默认缓存。

4.2 客户端数据请求 vs 服务端数据请求,怎么选

很多人刚接触时会纠结:到底什么时候在服务端取数,什么时候在客户端取数?我的经验法则就这么几条:

  • 页面初始内容需要被 SEO 索引、需要首屏快:用服务端取数。
  • 用户登录后才能看到的内容、频繁变化的内容、依赖浏览器 API 的内容:放在客户端请求。
  • 同一份数据在多个页面用:优先考虑服务端组件里调用同一个函数,配合 Reactcache()函数做请求去重。

React 的cache()函数是个好东西。假设两个服务端组件都要读取同一个用户信息,直接各自调用getUser()会发送两次请求;包一层cache()后,同一个请求期间内会复用同一份结果:

import { cache } from "react"; export const getUser = cache(async (id: string) => { const res = await fetch(`https://api.example.com/user/${id}`); return res.json(); });

这个cache只在单个请求生命周期内有效,不会跨用户串数据,不用担心安全问题。

4.3 静态生成、动态渲染与 ISR 的取舍

渲染模式的选型直接决定你的站点是"快"还是"新鲜"。纯静态生成(SSG)在构建时就把页面生成为 HTML,CDN 缓存友好,打开速度最快,适合博客文章、产品介绍这类内容基本不变页面。动态渲染则是每次请求都实时生成 HTML,适合强个性化页面。ISR 介于两者之间,设定一个revalidate时间,在时间窗口内用缓存,过了时间触发重新生成。

我在生产环境里最常用的组合是:营销页用静态生成,内容详情页用 ISR(比如revalidate: 3600,一小时更新一次),用户中心用动态渲染。一个页面是静态还是动态,直接看它是否使用了需要请求时才知道的数据。如果你在页面里读取了请求头里的 cookie,这个页面会自动变成动态渲染,Next.js 会在构建日志里明确标记动态部分,留意一下就好。

5. 中间件、鉴权与路由守卫:别把安全写在客户端

5.1 middleware.ts 能做什么、不能做什么

Next.js 的中间件(middleware)是一个很特殊的层,它在路由匹配之前执行,适合做重定向、请求头改写、基础鉴权。中间件文件要放在项目根目录或src/下,命名为middleware.ts。

一个典型的登录守卫逻辑:

// src/middleware.ts import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; const protectedPaths = ["/dashboard", "/settings"]; export function middleware(request: NextRequest) { const token = request.cookies.get("token")?.value; const { pathname } = request.nextUrl; if (protectedPaths.some((path) => pathname.startsWith(path)) && !token) { const loginUrl = new URL("/login", request.url); loginUrl.searchParams.set("from", pathname); return NextResponse.redirect(loginUrl); } return NextResponse.next(); } export const config = { matcher: ["/dashboard/:path*", "/settings/:path*"], };

中间件的执行环境是 Edge Runtime,不是完整的 Node 环境,所以不要在里面写fs、path这类 Node 独有模块。同时必须明确一点:中间件的鉴权属于"体验层面的防护",真正的数据安全底线要放在服务端组件和 Server Actions 里再校验一次。因为中间件可以被绕过(比如直接请求内部 API),不能把它当成唯一的安全边界。

5.2 服务端组件里的鉴权写法

在我的项目里,凡是需要身份信息的页面,第一行一定是验权逻辑:

// app/dashboard/page.tsx import { redirect } from "next/navigation"; import { getSession } from "@/lib/auth"; export default async function DashboardPage() { const session = await getSession(); if (!session) { redirect("/login"); } return <DashboardUI user={session.user} />; }

这里的关键是getSession()是在服务端执行,读取的是 HttpOnly Cookie 或服务端 session,浏览器端完全拿不到敏感信息。等将来你再写移动端或第三方接入时,这套逻辑也能直接复用。记住一个原则:能用服务端做的事,就别放到客户端做,尤其是鉴权、支付、数据落库这类高危操作。

5.3 Server Actions 使用心得

App Router 还带来了 Server Actions,允许你直接在前端表单里调用后端函数,类似 RPC:

"use server"; export async function createPost(formData: FormData) { const title = formData.get("title"); // 校验数据、写数据库、重定向... }

前端表单里这样用:

<form action={createPost}> <input name="title" required /> <button type="submit">发布</button> </form>

这个模式简化了"前端发请求、后端写接口、再处理返回"的繁复流程。不过要提醒一句,Server Actions 暴露的是函数入口,一定要在函数内部做权限校验和参数校验,不能天真地认为"只有我的页面能调用它"。

6. 实战中躲不开的几类问题:报错排查与细节优化

6.1 水合不一致(Hydration Mismatch)的应对

这是 Next.js 新手最常遇到的红屏错误之一,错误信息大致是 "Text content does not match server-rendered HTML" 或 "Hydration failed because the server rendered HTML didn't match the client"。原理是:服务端已经渲染出一份 HTML,客户端 React 加载后又渲染了一份做对比,两份不完全一致就会报错。

最常见的引发原因是new Date()、Math.random()、window相关代码在组件里直接执行。服务端执行时的时间、随机数和浏览器执行时不一样,比如组件里写今天是 {new Date().toLocaleDateString()},服务端和客户端一旦跨天执行就一定会出问题。

解决办法有两种。如果只是想展示客户端才能确定的内容,用"use client"组件配合useEffect加状态:

"use client"; export default function CurrentTime() { const [time, setTime] = useState(""); useEffect(() => { setTime(new Date().toLocaleTimeString()); }, []); return <span>{time}</span>; }

useEffect只会在浏览器端执行,所以服务端不会渲染时间内容,自然就不会对比失败。另外也可以用框架提供的suppressHydrationWarning属性跳过误差提醒,但这只是掩耳盗铃,如果不是确定自己知道在干什么,不建议用。

6.2 next/image 组件的缓存与域名白名单

Next.js 内置的Image组件非常强大,自动做懒加载、响应式尺寸、WebP 转换。但有一个让很多人上来就懵的报错:用了外链图片地址,控制台提示next/image的 hostname 没有配置。解决方法是打开next.config.ts,在images.remotePatterns里加白名单:

const nextConfig = { images: { remotePatterns: [ { protocol: "https", hostname: "images.example.com", }, ], }, }; export default nextConfig;

除了配置问题,Image组件另一个值得注意的点是它是按需优化的,生产环境会在.next/cache/images里生成优化后的图片缓存。如果你的图片更新很频繁,而 URL 没变,可能看到旧图,这时可以在Image组件里用key强制刷新,或者在文件名中加入版本号。

6.3 环境变量的坑:NEXT_PUBLIC_ 前缀

Next.js 里服务端和客户端共用.env文件,但只有以NEXT_PUBLIC_开头的变量会暴露到浏览器端。这个设计是为了防止密钥泄露,但坑也在这:很多人把接口地址写成API_BASE_URL,然后在客户端组件里访问process.env.API_BASE_URL,结果拿到undefined。

标准写法是:

# .env.local API_SECRET_KEY=sk-xxxxxx # 只在服务端可用 NEXT_PUBLIC_SITE_URL=https://example.com # 客户端服务端都可用

我自己的经验是:凡是要暴露给浏览器的变量,一定要显式加NEXT_PUBLIC_前缀,并在README里注明。还有个小技巧:在next.config.ts里可以用env字段手动注入变量,但一般没必要,直接用.env更直观。

6.4 构建时报错:类型错误、ESLint 规则、动态路由参数

next build比next dev严格得多,很多开发模式下不会报的问题构建时全冒出来了。最常见的是 TypeScript 类型不匹配和 ESLint 规则拦截。比如某个页面动态路由的params类型写成了string,实际是Promise<string>,开发时因为数据能正常拿到没报错,构建时类型检查却直接失败。这种问题没有捷径,只能按照报错提示逐个修。

另外一个构建相关的注意点是:构建过程中 Next.js 会尝试静态抓取页面内容,如果页面里有未捕获的错误,会导致构建失败。排查思路是打开next.config.ts,临时把eslint和typescript的构建检查关掉:

const nextConfig = { eslint: { ignoreDuringBuilds: true, }, typescript: { ignoreBuildErrors: true, }, };

但注意,这只是为了快速定位是不是类型问题导致的失败,修完之后要恢复开启,别把这两个开关留在生产配置里,否则等于放弃了框架自带的安全网。

7. 最后说点个人的使用体会

真正把 Next.js 用得顺手,不是背 API,而是建立一套"什么代码放在哪一层"的判断习惯。我的体会是:静态展示的代码全部丢服务端,交互逻辑单独抽客户端组件,数据请求能合并就合并,鉴权必须在服务端做,图片一律走Image组件,环境变量严格区分公开和私密。这套习惯一旦形成,新项目的开发速度会明显提升,而且页面的首屏性能和 SEO 表现是"天生就好",不需要后期专门优化。

如果你正在从零开始学,建议不要急着看各种复杂架构文章,先用自己的话写一个包含列表页、详情页、登录页的小项目,把 App Router 的动态路由、服务端取数、客户端交互、中间件鉴权各用一遍。踩完这几个最常见的坑之后,你再看任何 Next.js 的进阶内容都会觉得顺畅很多。扫码关注也好、收藏也好,都不如直接打开终端跑一条npx create-next-app@latest来得实在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询