Next.js App Router 全栈实战:从 SPA 的 SEO 痛点到服务端组件与 Hydration 水合机制
本文从一个真实问题出发:为什么用 Vite + React 写的 SPA 上线后百度收录几乎为零?答案藏在「服务器到底返回了什么」里。接着用 my-app 项目源码讲清 Next.js App Router 的文件路由约定、服务端组件与客户端组件的核心边界、客户端组件在 Next.js 里的「两次执行」、Hydration 水合机制,以及 route.ts 怎么写 RESTful 接口。
技术栈:Next.js 16.3.0 + React 19.2.8 + TypeScript + Tailwind v4。文中代码来自真实项目源码,运行未验证(未实际 npm run dev)。
文章目录
- Next.js App Router 全栈实战:从 SPA 的 SEO 痛点到服务端组件与 Hydration 水合机制
-
- 一、SPA 的 SEO 死穴:服务器只返回一个空壳
- 二、Next.js 怎么救场:SSR + Hydration 混合渲染
- 三、App Router 文件即路由:page / layout / route 三件套
- 四、服务端组件 vs 客户端组件:核心边界对比表
- 五、客户端组件的两次执行:Next.js 和纯 SPA 的关键差异
- 六、API Routes:route.ts 写后端接口
- 七、收藏资产:排错表与自检清单
-
- 排错表:常见错误 → 原因 → 处理
- 自检清单:写 Next.js 页面前的 5 个问题
- 一个真实的代码 bug(排错案例)
- 总结与下一步
一、SPA 的 SEO 死穴:服务器只返回一个空壳
要理解 Next.js 解决了什么,先得看清 SPA(Single Page Application)到底坑在哪。
传统 SPA 部署后,服务器上就三个静态文件:
Server(前端项目所在的服务器)
├── index.html ← 只有一个空 #root 的壳
├── main.js ← 打包后的所有 JS 代码
└── (其他 css/图片)
那个 index.html 长这样:
<!DOCTYPE html>
<html>
<body>
<div id="root"></div> <!– 空壳!–>
<script src="main.js"></script>
</body>
</html>
关键对比——同一个 URL,爬虫和用户看到的不一样:
| 爬虫(百度/Google) | 空 #root + <script> | 抓不到内容,SEO 为 0 |
| 真实用户 | 浏览器跑 JS 后的完整页面 | 体验好 |
爬虫不执行 JS(或执行不完整),所以 SPA 的内容对爬虫来说是「隐形」的。这就是 CSR(客户端渲染) 的本质——内容在客户端才渲染,服务器只给空壳。
首屏也慢:要等 下载 JS → 执行 JS → 渲染组件 → 请求数据,串行多步,白屏时间长。
二、Next.js 怎么救场:SSR + Hydration 混合渲染
Next.js 是 React 全栈框架,背靠 Vercel,核心卖点是 SEO + 首屏快。它的解法不是「更好的 SPA」,而是 SSR(服务端渲染)+ Hydration(水合) 的混合模式。
工作流程(六步):
核心区别:
- 纯 SPA(Vite):服务器只返回空 #root,内容等浏览器跑 JS 才出现 ❌
- Next.js:服务器先渲染一遍组件,把能渲染的静态内容塞进 HTML ✅
三、App Router 文件即路由:page / layout / route 三件套
Next.js App Router 的约定很简单:文件即路由。
- 文件夹 = URL 路径段
- page.tsx = 这个路由的页面组件
- layout.tsx = 包裹子路由的布局(自动套用)
- route.ts = API 接口(返回 JSON)
my-app 项目的实际路由表:
| / | app/page.tsx | 服务端组件 |
| /about | app/about/page.tsx | 服务端组件 |
| /todos | app/todos/page.tsx | 客户端组件 |
| /api/todos | app/api/todos/route.ts | API Route |
| /dashboard | app/dashboard/page.tsx | 服务端组件 |
| /dashboard/settings | app/dashboard/settings/page.tsx | 嵌套路由 |
嵌套布局:app/dashboard/layout.tsx 会自动包裹 dashboard/page.tsx 和 dashboard/settings/page.tsx,子路由切换时布局不重新渲染。
根布局 app/layout.tsx 还负责两件事——SEO 元信息和客户端路由导航:
import type { Metadata } from "next";
import Link from "next/link";
// 导出 metadata 做 SEO
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body>
<nav>
<ul>
<li><Link href="/about">About</Link></li>
<li><Link href="/">Home</Link></li>
<li><Link href="/dashboard">Dashboard 后台管理系统</Link></li>
</ul>
</nav>
{children}
</body>
</html>
);
}
两个细节:
- export const metadata 导出 SEO 元信息(title/description),这是 Next.js 的约定
- 用 <Link> 而不是 <a>:<a> 会整页刷新,<Link> 走客户端路由,局部更新 DOM,丝滑切换
四、服务端组件 vs 客户端组件:核心边界对比表
这是 Next.js 面试最高频考点。组件只有两类:
| 标记 | 什么都不写(默认) | 文件第一行 'use client' |
| 在哪执行 | 服务器上 | 用户浏览器里 |
| useState/useEffect | ❌ | ✅ |
| onClick 等事件 | ❌ | ✅ |
| window/localStorage | ❌ | ✅ |
| 直接 await 取数据 | ✅ | ❌(要用 useEffect 或 SWR) |
| 输出结果 | HTML 字符串 | 可交互的 React 组件 |
| 优点 | SEO 好、首屏快、数据安全 | 有交互能力 |
记忆技巧:
- 要交互(点按钮、输文字、存本地)→ 加 'use client'
- 纯展示(只是把数据渲染出来)→ 不加,走服务端组件(自带 SEO)
导入规则:
- ✅ 服务端组件可以 import 客户端组件(客户端组件就是「边界」,边界里子组件自动都是客户端组件)
- ❌ 客户端组件不能 import 服务端组件(已经跑浏览器了,没法再跑服务端逻辑)
看一下首页 app/page.tsx,它是默认的服务端组件:
// 服务器端组件
// 在服务器端 react node 的方式运行
// jsx -> html
export default function Home() {
return (
<h1>Hello World</h1>
);
}
注释点破了本质:React 组件不是只能在浏览器跑,react(js/node 方式)也能在服务器跑。服务器上没有 DOM,组件函数执行后输出的是 HTML 字符串。
五、客户端组件的两次执行:Next.js 和纯 SPA 的关键差异
这是最容易混的点。app/todos/page.tsx 是客户端组件:
'use client'; // 客户端组件标记
import { useState, useEffect } from 'react';
export default function TodosPage() {
const [todos, setTodos] = useState([]); // 省略类型注解以聚焦逻辑
const [text, setText] = useState("");
const fetchTodos = async () => {
const res = await fetch("/api/todos");
const data = await res.json();
setTodos(data);
}
useEffect(() => {
fetchTodos();
}, [])
return (
<div>
<h1>待办事项</h1>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={handleAdd}>添加</button>
<ul>
{todos.map((item) => (
<li key={item.id}>{item.content}</li>
))}
</ul>
</div>
)
}
纯 SPA(Vite):服务器只返回空 #root,内容等浏览器跑 JS 才出现,源码里什么都没有。
Next.js 客户端组件三步流程:
- 不执行 useEffect(浏览器 API)
- useState 取初始值(所以 todos 是 [])
- 把能渲染的 JSX(<h1>、空 <ul>)拼成 HTML
- 显示 HTML(用户立刻看到标题)
- 下载 JS bundle
- Hydration:把静态 HTML 激活成 React 组件
- useEffect 触发 → fetch 数据 → setTodos → 列表出来
关键结论(纠正一个常见误区):
| 纯 SPA(Vite) | 空 #root | ❌ 什么都没有 |
| Next.js 服务端组件 | 带内容 HTML | ✅ 完整 |
| Next.js 客户端组件 | 带静态部分 HTML | ✅ 和 state 无关的部分在,动态部分用初始值占位 |
Hydration 水合就是第 3 步:服务器返回静态 HTML(看得见但点不动),浏览器下载 JS 后把静态 HTML 激活成可交互 React 组件,绑定 onClick 等事件。
类比理解:
- 服务器给你一张画好的画(HTML,看得见)
- JS 加载后给画注入灵魂(能点能动了)
六、API Routes:route.ts 写后端接口
Next.js 不只是写页面,还能写后端接口。在 app/api/<接口路径>/route.ts 导出 GET/POST 等方法,返回 JSON,满足 RESTful。
my-app 的 app/api/todos/route.ts:
// 模块级数组做内存存储(学习用,重启丢失)
let todos = [
{ id: 1, content: '学习AppRouter', completed: true},
{ id: 2, content: 'next.js 个人官网开发', completed: false},
]
// GET /api/todos —— 返回所有待办
export async function GET() {
return Response.json(todos); // Next.js 封装好的 Response
}
// POST /api/todos —— 新增待办
export async function POST(req: Request) {
const body = await req.json();
const newItem = {
id: +Date.now(),
content: body.content,
completed: false,
}
todos.push(newItem);
return Response.json(newItem);
}
要点:
- 用 Response.json() 返回 JSON(Next.js 封装,不用 res.send)
- 用 req.json() 解析请求体
- 方法名必须大写匹配 HTTP 动词(GET/POST/PUT/DELETE)
- 模块级 let todos 做内存存储,重启丢失(生产环境要接数据库)
前端怎么调? 在 todos/page.tsx 里:
// GET:默认 method,不需要 headers/body
const res = await fetch("/api/todos");
const data = await res.json();
// POST:需要 method、Content-Type 和 body
await fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: text }),
});
GET 和 POST 的区别:GET 是默认 method,不需要 headers/body;POST 要指定 method: 'POST'、Content-Type: application/json 和 body。
七、收藏资产:排错表与自检清单
排错表:常见错误 → 原因 → 处理
| 服务端组件用了 useState 报错 | 服务端组件不能 hooks | 文件顶部加 'use client' |
| 用 <a href="/about"> 页面整页刷新 | <a> 是原生跳转 | 改用 <Link href="/about"> |
| 客户端组件 import 服务端组件报错 | 客户端组件不能再跑服务端逻辑 | 把服务端逻辑拆成 props 传进去,或重构成客户端组件 |
| useEffect 里的 fetch 没触发 | 依赖数组写错 | 空依赖数组 [] 只跑一次,确认写法 |
| API Route 返回的不是 JSON | 用了 res.send | 改用 Response.json() |
自检清单:写 Next.js 页面前的 5 个问题
- 这个页面需要 SEO 吗?需要 → 优先服务端组件
- 这个页面有交互(点击/输入/本地存储)吗?有 → 加 'use client'
- 跳转用的是 <Link> 还是 <a>?必须是 <Link>
- API Route 的方法名是不是大写的 HTTP 动词?
- metadata 是不是只在服务端组件/layout 里导出?
一个真实的代码 bug(排错案例)
todos/page.tsx 的 handleAdd 函数有 bug:
const handleAdd = async () => {
if (!text.trim()) {
return; // ← 这里 return 了
await fetch("/api/todos", { // ← 不会执行!
method: "POST",
// …
});
}
}
return 之后的 fetch 不会执行。正确写法:把 fetch 移到 if 块外面,当 text 非空时才执行。这个 bug 可以直接拿去当面试排错题。
总结与下一步
Next.js 的价值不在「又一个 React 框架」,而在于用「服务端渲染 + 客户端水合」的混合模式,把 SPA 的交互体验和传统网站的 SEO/首屏速度同时拿下。App Router 用文件即路由的约定,让全栈开发回归「写组件」本身。
可迁移的判断:写 Next.js 项目时,默认用服务端组件,只在需要交互的最小范围切到客户端组件——这是性能和体验兼得的关键。
下一步:
- 给 my-app 的 route.ts 加一个 DELETE 方法
- 修复 handleAdd 的 bug,并在添加成功后调用 fetchTodos() 刷新列表
- 给 /dashboard 加一个 /dashboard/profile 嵌套路由
- 把 route.ts 的内存数组换成真实数据库(如 Prisma + SQLite)
运行未验证:本文代码基于源码静态分析,未实际运行 npm run dev。生产环境请以官方文档为准。
标签:Next.js, React, SSR, SEO, 全栈开发
网硕互联帮助中心


评论前必须登录!
注册