从 CSR 到 Server Component:用 Next.js 16 App Router 吃透路由、布局与 SEO
前端开发者第一次接触 Next.js,最容易产生两个疑问:
- 已经有 React 了,为什么还需要 Next.js?
- Next.js 所说的服务端渲染,到底解决了什么问题?
要回答它们,不能只记住 page.tsx、layout.tsx 这些文件名,而要先理解页面究竟在哪里生成,以及浏览器第一次请求 URL 时究竟拿到了什么。
本文会从 CSR 与服务端渲染的差异出发,逐步实现首页、关于页、待办页、后台页和设置页,并通过根布局与嵌套布局理解 App Router 的核心工作方式。最后再回到 SEO,看看一个对搜索引擎友好的页面需要具备哪些条件。
本文使用 Next.js 16.3.3、React 19.2.8、TypeScript 5 和 Tailwind CSS 4。
一、先分清 Next.js、Nuxt 和 Nest
这几个名字很接近,但它们解决的问题并不相同:
- Next.js 是 React 生态中的全栈框架;
- Nuxt 是 Vue 生态中的全栈框架;
- Nest 是偏后端的 Node.js 框架。
Next.js 不只是"帮 React 配好路由"的工具。它既能组织前端页面,也具备编写服务端逻辑的能力,还提供了服务端渲染、静态预渲染、字体优化和元数据管理等开箱即用的能力。
这类能力对内容站、产品官网和需要自然搜索流量的 AI 产品尤其重要。它们不仅要有接近单页应用的交互体验,还希望搜索引擎能够直接理解每个 URL 的标题、摘要和正文。
除了传统 SEO,还可以看到 GEO(Generative Engine Optimization,生成式引擎优化) 这个概念。用户的信息入口正在从百度、Google 等搜索引擎扩展到豆包、DeepSeek 等生成式产品;GEO 关注的是内容能否在生成式回答中被理解、呈现,并把用户带回具体内容或产品链接。本文不展开额外的 GEO 技巧,但无论入口如何变化,清晰的页面主题和有价值的正文始终是基础。
二、SPA 的体验很好,为什么 SEO 仍然是问题
传统 React SPA 通常采用 CSR,也就是 Client Side Rendering,客户端渲染。
假设应用中有一个待办页面,前端路由可能会写成类似下面这样:
tsx
<Route path="/todos" element={<Todos />} />
访问 /todos 时,服务器往往仍然返回同一个 index.html。其中最关键的内容可能只有一个用于挂载应用的节点:
html
<div id="root"></div>
<script src="main.js"></script>
随后浏览器下载并执行 JavaScript,React 才把 Todos 组件渲染进 #root。如果组件还要在 useEffect 中请求数据,页面正文会出现得更晚。
整个过程可以概括为:
text
服务器返回 HTML 空壳和 JavaScript
↓
浏览器下载并执行 JavaScript
↓
React 在浏览器中生成页面
↓
用户看到完整内容
CSR 的优点非常明显:页面切换流畅、组件可以在前端按需挂载,切换路由时也不必每次整页刷新。很多移动端应用中的 WebView 页面,本质上也在利用 Web 技术快速复用界面和业务逻辑。
但从 SEO 的角度看,CSR 增加了理解页面的成本。搜索引擎第一次获得的 HTML 可能没有正文,必须继续执行 JavaScript、等待异步请求,才能知道页面讲了什么。现代搜索引擎并非完全不能处理 JavaScript,但与正文已经存在于 HTML 中的页面相比,纯 CSR 页面的抓取和索引链路更长,也更不稳定。
问题的根本不是"React 不支持 SEO",而是:有价值的内容是否已经出现在服务器返回的 HTML 中。
三、把 React 组件放到服务端运行
React 组件本质上是根据数据描述 UI 的函数。如果一个组件只做"获取数据并生成界面",不依赖浏览器 DOM、点击事件或 useEffect,它就很适合在服务器执行。
在传统前后端分离项目中,访问待办接口可能得到一个 JSON 数组,浏览器再根据 JSON 生成 UI:
text
/api/todos → JSON 数据 → 浏览器执行 React → 页面
在具备服务端渲染能力的全栈项目中,服务器可以把组件和数据结合起来,先生成可直接显示的页面内容:
text
组件 + todos 数据 → 服务端生成 UI → HTML → 浏览器
对应的请求流程是:
text
浏览器请求 URL
↓
服务器找到对应的页面组件
↓
组件获取数据并生成 UI
↓
服务器把结果作为 HTML 返回
↓
浏览器直接显示首屏内容
这就是理解服务端渲染的起点:以前主要让浏览器负责生成页面,现在服务器也可以提前完成这部分工作。
不过还要注意一个容易混淆的边界:
- Server Component 描述组件主要在哪里执行;
- SSR 强调页面是否在每次请求到来时动态生成;
- 如果页面没有动态数据,Next.js 也可能在构建阶段提前生成静态 HTML。
因此,"默认是 Server Component"并不等于"每次请求都执行一次 SSR"。本文的几个页面都没有动态取数,构建时可以预渲染成静态页面;但它们的首屏 HTML 同样能够直接包含正文,对 SEO 依然友好。
四、创建一个 Next.js 全栈项目
使用官方脚手架创建项目:
bash
npx create-next-app@latest
选择默认配置后,项目包含 React、TypeScript、ESLint、Tailwind CSS 和 App Router。常用脚本如下:
json
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
}
}
它们分别用于启动开发服务器、生成生产构建、运行生产服务和检查代码规范。
本文只关注真正参与页面组织的部分,目录可以简化为:
text
app/
├── layout.tsx
├── page.tsx
├── globals.css
├── about/
│ └── page.tsx
├── todos/
│ └── page.tsx
└── dashboard/
├── layout.tsx
├── page.tsx
└── settings/
└── page.tsx
这棵目录树已经把所有页面的 URL 结构表达出来了。
五、App Router:文件系统就是路由表
App Router 的核心思想是"约定优于配置":不需要手写一张路由表,文件夹表示 URL 片段,page.tsx 表示这个路径可以被访问。
上面的目录会形成如下映射:
| 文件位置 | URL | 页面作用 |
|---|---|---|
app/page.tsx |
/ |
首页 |
app/about/page.tsx |
/about |
关于页 |
app/todos/page.tsx |
/todos |
待办页 |
app/dashboard/page.tsx |
/dashboard |
后台首页 |
app/dashboard/settings/page.tsx |
/dashboard/settings |
后台设置页 |
1. 首页
首页只是一个普通 React 函数组件:
tsx
// Server Component:React 在服务端环境中运行并生成页面内容
export default function Home() {
return <h1>Hello nextjs</h1>;
}
文件顶部没有 'use client',所以它默认是 Server Component。它不需要 useEffect,也没有点击事件,只负责生成标题,非常适合在服务端执行。
2. 关于页
在 about 路径段中导出页面组件:
tsx
function About() {
return <h1>About Us</h1>;
}
export default About;
启动开发服务器后,直接访问 http://localhost:3000/about 就能到达这个页面,不需要额外配置 /about 路由。
3. 待办页
待办页当前只用于验证路由:
tsx
export default function TodosPage() {
return <>Todos</>;
}
它对应 /todos。这里使用 Fragment 返回文本,避免为了一个根节点额外添加无意义的标签。
4. 后台首页与设置页
后台首页对应 /dashboard:
tsx
export default function Page() {
return <h1>Hello, Dashboard! 后台管理系统</h1>;
}
设置页位于更深一层的目录,因此对应 /dashboard/settings:
tsx
export default function Page() {
return <h1>Hello, Settings!</h1>;
}
这里两个组件都可以命名为 Page,因为它们属于不同模块,最终 URL 由目录位置决定,而不是由函数名决定。
六、Root Layout:只写一次的全站公共骨架
一个网站通常有多个页面共享导航、字体和整体结构。如果每个页面都重复这些代码,不仅啰嗦,也很难统一维护。
App Router 使用 layout.tsx 解决这个问题。应用根目录中的布局会包裹所有页面:
tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import Link from "next/link";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<nav>
<ul>
<li><Link href="/">首页</Link></li>
<li><Link href="/about">关于</Link></li>
<li><Link href="/dashboard">后台管理</Link></li>
</ul>
</nav>
{children}
</body>
</html>
);
}
这段代码有五个值得注意的部分。
1. children 是当前路由的页面内容
布局并不知道当前访问的是首页、关于页还是后台页,它只预留 {children}。Next.js 匹配 URL 后,会把对应页面放到这里。
访问 /about 时,可以把最终结构理解为:
tsx
<RootLayout>
<About />
</RootLayout>
因此,根导航只写一次,却会出现在所有页面上。
2. 根布局负责 <html> 和 <body>
根布局给整个应用提供 HTML 骨架。h-full、min-h-full 和 flex flex-col 用来建立至少占满视口高度的纵向布局;字体变量也被挂到 <html> 上,供全局样式使用。
如果页面主要使用中文,实际项目中可以把 lang="en" 调整为 lang="zh-CN",让文档语言与正文一致。
3. next/font 统一管理字体
Geist 和 Geist_Mono 分别生成 CSS 变量:
tsx
variable: "--font-geist-sans"
variable: "--font-geist-mono"
全局样式再把它们映射到普通字体和等宽字体。布局只需把生成的变量类名放到根元素,不必在每个页面重复配置。
这里还要注意"声明字体变量"和"真正使用字体"的区别。当前 body 明确设置了 Arial, Helvetica, sans-serif,页面实际会优先使用这组字体;Geist 变量虽然已经注册并映射到 Tailwind 主题,但还需要给元素添加 font-sans、font-mono,或者在 CSS 的 font-family 中直接引用对应变量,字体才会真正应用。
4. Link 负责应用内部导航
导航栏没有使用普通字符串拼接路由,而是使用 Next.js 提供的 Link:
tsx
<Link href="/dashboard">后台管理</Link>
href 与目录产生的 URL 一一对应。这样既保留真实链接的语义,也能使用框架提供的客户端路由体验。
5. LayoutProps 给 children 提供类型
LayoutProps<"/"> 是 Next.js 生成的全局类型辅助工具。对于根布局,它会把 children 标注为 React.ReactNode,同时与具体布局路由关联,避免在严格 TypeScript 配置下出现隐式 any。
七、嵌套 Layout:只包裹某一个路由分支
根布局适合全站导航,但后台通常还需要自己的二级导航。此时可以在 dashboard 路径段中继续创建 layout.tsx:
tsx
import Link from "next/link";
export default function DashboardLayout({
children,
}: LayoutProps<"/dashboard">) {
return (
<div>
<nav>
Nav
<Link href="/dashboard/settings">Settings</Link>
</nav>
{children}
</div>
);
}
这个布局不会影响 /about 或 /todos,只会包裹 /dashboard 及其子路由。
访问 /dashboard/settings 时,实际嵌套关系可以理解为:
tsx
<RootLayout>
<DashboardLayout>
<SettingsPage />
</DashboardLayout>
</RootLayout>
把几个地址放在一起比较,就更容易看清布局的作用范围:
text
/ RootLayout → Home
/about RootLayout → About
/todos RootLayout → TodosPage
/dashboard RootLayout → DashboardLayout → DashboardPage
/dashboard/settings RootLayout → DashboardLayout → SettingsPage
这就是嵌套路由和嵌套布局之间的对应关系:目录负责 URL 层级,布局负责 UI 层级。
示例中的 children 必须有明确类型。由于 TypeScript 开启了严格模式,如果直接写成下面这样:
tsx
export default function DashboardLayout({ children }) {
// ...
}
类型检查会提示 children 隐式具有 any 类型。使用 LayoutProps<"/dashboard"> 后,代码既保持简洁,也通过了布局路由约束。
八、全局样式与 Tailwind CSS 如何接入布局
全局样式通过根布局中的这一行进入整个应用:
tsx
import "./globals.css";
样式文件首先引入 Tailwind CSS:
css
@import "tailwindcss";
随后定义亮色、暗色背景以及字体变量:
css
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
这里的职责分工很清楚:
:root保存全局颜色变量;@theme inline把 CSS 变量映射给 Tailwind 主题;prefers-color-scheme: dark根据系统偏好切换深色配色;body应用页面的背景、前景色,并把当前字体栈设为 Arial、Helvetica 和通用无衬线字体。
页面组件因此可以专注于结构,而无需在每一个页面重复基础样式。
九、SEO 不是只加一个 title,而是三层共同作用
一个页面要对搜索引擎友好,至少要考虑三个层次。
第一层:用元数据说明"你是谁"
传统 HTML 会直接编写:
html
<title>页面标题</title>
<meta name="description" content="页面摘要" />
<meta name="keywords" content="关键词" />
在 App Router 中,可以通过带类型的 metadata 对象描述这些信息:
tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Next.js App Router 入门",
description: "理解 Next.js 的文件路由、嵌套布局、服务端组件与 SEO。",
keywords: ["Next.js", "App Router", "Server Component", "SEO"],
};
脚手架默认的 Create Next App 和 Generated by create next app 只适合占位。真正发布网站时,标题应该回答"这是谁",描述应该回答"它做什么、能提供什么价值"。
第二层:提供真正有价值的正文
元数据只能概括页面,用户最终访问页面是为了内容。没有正文,再精心设计的关键词也无法替代内容本身。
对于文章站来说,每一篇文章都应该有独立 URL、独立标题和完整正文。例如 /post/id 可以表示某一篇具体文章,而不是让整个站点始终只有一个入口页面。可被索引的高质量页面越完整,整个内容站越容易积累搜索价值。
第三层:让首屏 HTML 直接包含内容
如果标题和正文只能等浏览器执行 JavaScript 后才出现,搜索引擎就要付出额外的渲染成本。服务端组件、SSR 或静态预渲染可以让服务器返回的 HTML 直接带上主要内容,这才是渲染层面对 SEO 的帮助。
可以把三层关系概括为:
text
元数据:告诉搜索引擎页面是什么
正文内容:告诉用户页面有什么价值
服务端可读 HTML:让内容更直接地被抓取和理解
三者缺一不可。只做服务端渲染却没有内容,不会自动获得良好 SEO;只写元数据但正文仍是空壳,也没有解决渲染链路的问题。
十、从 URL 到页面,完整走一遍
最后以 /dashboard/settings 为例,把整条链路串起来:
- 浏览器请求
/dashboard/settings; - App Router 根据目录找到设置页;
- 设置页先被
dashboard布局包裹,得到后台导航; - 整个后台区域再被根布局包裹,得到全站导航、字体和 HTML 骨架;
- Next.js 在服务端生成可直接显示的页面结果;
- 浏览器拿到包含主要内容的 HTML 并显示页面;
- 后续点击
Link时,继续使用 Next.js 的应用内导航能力。
这个过程同时体现了 App Router 的三个重要约定:
page.tsx决定某个 URL 展示什么;layout.tsx决定一组页面共享什么;- 目录嵌套同时决定 URL 层级与布局嵌套关系。
总结
Next.js 的价值不只是让 React 项目多了几个特殊文件,而是把路由、布局、服务端执行和 SEO 串成了一套统一模型。
从本文的几个页面可以得到这些关键结论:
- CSR 在浏览器中生成页面,交互体验好,但首个 HTML 可能缺少正文;
- Next.js App Router 中的组件默认是 Server Component,适合在服务端生成内容;
- Server Component 与请求时 SSR 不是同一个概念,静态预渲染同样可以返回完整 HTML;
page.tsx配合目录自动形成路由,不需要手写路由表;- 根
layout.tsx提供全站骨架,嵌套layout.tsx提供局部公共界面; Link把各个文件路由连接起来,children则把页面填入对应布局;- SEO 需要元数据、优质正文和服务端可读 HTML 三层配合。
掌握这些内容后,再去学习动态路由、服务端数据请求和后端接口,就会有一条非常清楚的主线:无论项目继续变得多复杂,本质仍然是在回答两个问题------某个 URL 应该找到哪个页面,以及这个页面应该由哪些布局和数据共同生成。