为什么 Next.js 要把组件切成两半?从一个 SEO 死穴说起
写 Vite + React 的 SPA 上线后,打开百度站长工具一看,收录几乎为零------这不是配置问题,是 SPA 的结构性缺陷。Next.js 给出的解法不是「更好的 SPA」,而是把 React 组件切成两半:一半跑在服务器(换 SEO),一半跑在浏览器(换交互),中间靠 Hydration 水合缝合。
本文用 my-app 项目源码讲清这套设计的取舍逻辑:App Router 文件路由约定、服务端/客户端组件的核心边界、客户端组件在 Next.js 里的「两次执行」、route.ts 怎么写 RESTful 接口。技术栈 Next.js 16.3.0 + React 19.2.8,运行未验证 (未实际 npm run dev)。
一个 SEO 死穴:SPA 的服务器只返回空壳
SPA 部署后,服务器上就三个静态文件:index.html、main.js、一些 css。index.html 长这样:
html
<body>
<div id="root"></div> <!-- 空壳 -->
<script src="main.js"></script>
</body>
同一个 URL,爬虫和用户看到的完全不一样:
- 爬虫(百度/Google):拿到空
#root+<script>,不执行 JS,抓不到内容,SEO 为 0 - 真实用户:浏览器跑完 JS 后看到完整页面
这就是 CSR(客户端渲染) 的本质------内容在客户端才渲染,服务器只给空壳。首屏也慢:下载 JS → 执行 JS → 渲染组件 → 请求数据,串行多步,白屏时间长。
Next.js 的解法不是「更好的 SPA」,而是「服务器先画一遍」
Next.js 是 React 全栈框架,背靠 Vercel。它的核心思路是 SSR + Hydration 混合:服务器先把组件渲染成带内容的 HTML 返回,爬虫和用户都能立刻看到内容;JS 加载后再把静态 HTML「激活」成可交互组件。
工作流程六步:
- 浏览器请求 → Next.js 服务器执行对应
page.tsx - 服务器取数据 → 把数据填进组件 → 渲染成 HTML
- 服务器返回「带内容的 HTML」
- 浏览器直接显示(首屏秒开,爬虫抓得到)
- 下载 JS bundle
- Hydration 水合:静态 HTML 激活成可交互 React 组件
关键差异(纠正一个常见误区):纯 SPA 服务器返回空 #root;Next.js 即使是 'use client' 组件,服务器也会先渲染一遍静态内容塞进 HTML。所以「Next.js 客户端组件 = 空 HTML」是错的,只有纯 SPA 才空。
真正的取舍:把组件切成两半
Next.js 的设计哲学是「默认服务端,按需客户端」。组件只有两类:
| 服务端组件 | 客户端组件 | |
|---|---|---|
| 标记 | 默认 | 文件第一行 'use client' |
| 在哪执行 | 服务器 | 浏览器 |
| hooks/事件/window | ❌ | ✅ |
| 直接 await 取数据 | ✅ | ❌(用 useEffect) |
| 输出 | HTML 字符串 | 可交互组件 |
记忆技巧:
- 要交互(点按钮、输文字、存本地)→ 加
'use client' - 纯展示(渲染数据)→ 不加,走服务端组件(自带 SEO)
导入规则是个常见面试题:
- ✅ 服务端组件可以 import 客户端组件(客户端组件是「边界」,边界里子组件自动都是客户端组件)
- ❌ 客户端组件不能 import 服务端组件(已经跑浏览器了,没法再跑服务端逻辑)
看首页 app/page.tsx,它是默认服务端组件:
tsx
// 服务器端组件
// 在服务器端 react node 的方式运行
// jsx -> html
export default function Home() {
return <h1>Hello World</h1>;
}
注释点破了本质:React 组件不是只能在浏览器跑,react(js/node 方式)也能在服务器跑。服务器上没有 DOM,组件函数执行后输出的是 HTML 字符串。
客户端组件的「两次执行」:和纯 SPA 的关键差异
这是 Next.js 最容易混的点。app/todos/page.tsx 是客户端组件:
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>
)
}
客户端组件在 Next.js 里的「两次执行」:
- 【服务器端】 静态执行一次:不跑
useEffect,useState取初始值(todos 是[]),把能渲染的 JSX(<h1>、空<ul>)拼成 HTML - 【浏览器端】 拿到带内容的 HTML 后,下载 JS,Hydration 激活成交互组件,
useEffect触发 fetch 数据,setTodos更新列表
对比表(这是面试可以直接背的):
| 服务器返回 | 源码能看到内容 | |
|---|---|---|
| 纯 SPA(Vite) | 空 #root |
❌ 什么都没有 |
| Next.js 服务端组件 | 带内容 HTML | ✅ 完整 |
| Next.js 客户端组件 | 带静态部分 HTML | ✅ 和 state 无关的部分在,动态部分用初始值占位 |
Hydration 水合 就是第 2 步:静态 HTML(看得见但点不动)→ 激活成可交互组件(绑定 onClick)。类比:服务器给你画好的画,JS 加载后注入灵魂。
App Router 的文件路由约定:page / layout / route
Next.js App Router 的约定就一句话:文件即路由。
- 文件夹 = URL 路径段
page.tsx= 页面组件layout.tsx= 包裹子路由的布局(自动套用,子路由切换时不重新渲染)route.ts= API 接口(返回 JSON)
my-app 的实际路由表:
| URL | 文件 | 类型 |
|---|---|---|
/ |
app/page.tsx |
服务端组件 |
/about |
app/about/page.tsx |
服务端组件 |
/todos |
app/todos/page.tsx |
客户端组件 |
/api/todos |
app/api/todos/route.ts |
API Route |
/dashboard/settings |
app/dashboard/settings/page.tsx |
嵌套路由 |
根布局 app/layout.tsx 还负责 SEO 元信息和客户端路由:
tsx
import type { Metadata } from "next";
import Link from "next/link";
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="/dashboard">Dashboard 后台管理系统</Link></li>
</ul>
</nav>
{children}
</body>
</html>
);
}
两个细节值得记:
export const metadata导出 SEO 元信息,是 Next.js 约定(只能在服务端组件/layout 里导出)- 用
<Link>而不是<a>:<a>整页刷新,<Link>走客户端路由,局部更新 DOM
API Routes:route.ts 怎么返回 JSON
Next.js 不只是写页面,app/api/<接口路径>/route.ts 可以写后端接口。my-app 的 app/api/todos/route.ts:
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);
}
// 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()是 Next.js 封装的返回 JSON 方法(不用res.send)req.json()解析请求体- 方法名必须大写匹配 HTTP 动词(
GET/POST/PUT/DELETE) - 模块级
let todos做内存存储,重启丢失(生产要接数据库)
前端调用时 GET 和 POST 写法不同:
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 }),
});
收藏:排错表 + 自检清单
排错表
| 错误现象 | 原因 | 处理 |
|---|---|---|
服务端组件用 useState 报错 |
服务端组件不能 hooks | 顶部加 'use client' |
<a href="/about"> 整页刷新 |
<a> 是原生跳转 |
改用 <Link href> |
| 客户端组件 import 服务端组件报错 | 不能再跑服务端逻辑 | 拆成 props 传,或重构成客户端组件 |
| API Route 返回不是 JSON | 用了 res.send |
改用 Response.json() |
一个真实的代码 bug(排错案例)
todos/page.tsx 的 handleAdd 有 bug:
tsx
const handleAdd = async () => {
if (!text.trim()) {
return; // ← 这里 return 了
await fetch("/api/todos", { // ← 不会执行!
method: "POST",
// ...
});
}
}
return 之后的 fetch 不执行。正确写法:把 fetch 移到 if 块外,当 text 非空时才执行。这个 bug 可以直接当面试排错题。
写 Next.js 页面前的 5 个自检问题
- 这个页面需要 SEO 吗?需要 → 优先服务端组件
- 有交互(点击/输入/本地存储)吗?有 → 加
'use client' - 跳转用的是
<Link>还是<a>?必须<Link> - API Route 方法名是不是大写 HTTP 动词?
metadata是不是只在服务端组件/layout 里导出?
写在最后
Next.js 的设计本质是个取舍:默认服务端组件换 SEO 和首屏,只在需要交互的最小范围切到客户端组件。这个取舍背后的判断是------SEO 和交互不能兼得的时代过去了,但要靠「组件边界」来划分职责,而不是把整个应用都丢给浏览器。
记住这一点,比记住任何 API 都重要:客户端组件是边界,不是默认 。下次写 Next.js 项目时,先问自己「这块能不能不交互」,能就用服务端组件,不能才加 'use client'。
运行未验证:本文代码基于 my-app 源码静态分析,未实际运行。生产环境请以官方文档为准。
标签:Next.js、React、SSR、前端架构、全栈