前端同学写官网、内容站、AI 产品落地页,最怕什么?
------页面做得再漂亮,百度 Google 搜不到,流量为 0。
SPA 体验确实好,但爬虫看到的是空 #root;Next.js 背靠 Vercel,一个框架同时搞定页面和 API,把 React 组件的渲染从浏览器搬到了服务端。这篇文章带你从零搞懂 Next.js 全栈与 SEO 的底层逻辑,并用一个待办事项 Demo 跑通 App Router、Server Component、API Routes。
读完你就能上手:文件即路由、服务端组件、客户端组件水合、API 接口,一套全栈流程直接落地。
一、SPA 的体验与 SEO 的代价
先聊聊我们熟悉的 SPA(Single Page Application,单页应用)。
SPA 的好处很明显:
- 交互流畅,组件在前端挂载,用
useEffect异步请求数据,不需要刷新页面; - 前端路由切换快,体验接近原生 App;
- 一套 HTML + JS 跑在 WebView 里,就能覆盖 iOS / Android 两端。
你会发现,现在很多移动端 App 里 80% 的页面都是 SPA 做的。为什么?因为 App 不需要 SEO,用户是从应用商店下载的,不是从百度搜进来的。
但换成官网、博客、内容站、AI 产品介绍页,情况就完全不同了------这些页面要靠搜索引擎吃饭。
SPA 的短板非常致命:
爬虫通过 URL 抓取页面时,拿到的是这样的 HTML:
xml
<!DOCTYPE html>
<html>
<body>
<div id="root"></div>
<script src="/main.js"></script>
</body>
</html>
#root 是空的,真正的内容要等 JS 下载、执行、异步请求数据后才渲染出来。搜索引擎爬虫可没耐心等你异步加载完,所以你的页面在搜索结果里就是一片空白。
PC 时代 SEO 是命,移动端时代 App 是超级入口,但内容型产品依然靠搜索引擎吃饭。
所以我们需要一种方案:既能保留 React 的开发体验,又能在服务端把 HTML 渲染好再返回给爬虫。
这就是 Next.js 的核心价值。
二、CSR 与 SSR 的本质:组件到底在哪里渲染?
先厘清两个概念:
- CSR(Client Side Rendering) :组件在浏览器里挂载、渲染。用户看到页面前,浏览器要下载 JS、执行 React、请求数据,然后才渲染到
#root。 - SSR(Server Side Rendering) :组件在服务端用 Node 跑一遍,把 JSX + 数据编译成 HTML 字符串,直接返回完整页面。爬虫拿到就能解析。
前后端分离的项目里,/todos 这个 URL 返回的是 JSON 数据:
json
[{ "id": 1, "content": "学习 App Router", "completed": true }]
爬虫不认 JSON,它要的是 HTML。
而 Next.js 的全栈项目里,/todos 返回的是 React 组件编译后的 HTML:
css
<ul>
<li>学习 App Router</li>
<li>Next.js 个人官网开发</li>
</ul>
SSR(Server-Side Rendering,服务端渲染)
渲染发生在服务器上。
- 服务器收到请求后,执行组件代码,把 JSX + 数据编译成完整的 HTML 字符串,返回给浏览器。
- 浏览器直接显示 HTML,无需等待 JS 下载执行。
- 优点:首屏快,SEO 友好(爬虫直接拿到内容)。
- 缺点:服务器压力大,交互需要额外水合。
比喻:餐厅把煮好的水饺端给你,你直接吃。
典型场景:官网、博客、内容站、需要被搜索引擎收录的页面。
CSR(Client-Side Rendering,客户端渲染)
渲染发生在浏览器里。
- 服务器只返回一个空壳 HTML(如
<div id="root"></div>)和 JavaScript 文件。 - 浏览器下载 JS,执行 React,在客户端挂载组件、请求数据、更新 DOM。
- 优点:交互流畅,切换页面不刷新,体验接近原生 App。
- 缺点:首屏慢(等 JS),SEO 差(爬虫看到空壳)。
比喻:餐厅给你面团和馅,你自己包、自己煮。
典型场景:后台管理系统、移动端 App 内嵌页面、不需要 SEO 的强交互应用。
说白了,组件到底在哪里渲染,决定了 SEO 的根本。
在服务端渲染,SEO 就好;在客户端渲染,SEO 就差。
三、Next.js 项目结构与约定式路由
3.1 创建项目
lua
npx create-next-app@latest
选择默认配置即可,你会得到 TypeScript、Tailwind CSS、ESLint 一套全家桶。
3.2 文件即路由
Next.js 的 App Router 有一个核心思想:约定大于配置。
不需要像 React Router 那样写一堆 <Route>,你只需要在 app/ 目录下建文件夹和文件,路由就自动生成了。
app/page.tsx→ 首页/app/about/page.tsx→/aboutapp/dashboard/page.tsx→/dashboardapp/dashboard/settings/page.tsx→/dashboard/settings
嵌套路由通过文件夹实现,布局文件 layout.tsx 负责公共部分。
我们看一个全局布局:
javascript
// app/layout.tsx
import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";
export const metadata: Metadata = {
title: "我的全栈应用",
description: "一个 Next.js 全栈项目,SEO 友好",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body>
<nav>
<ul>
<li><Link href="/">首页</Link></li>
<li><Link href="/about">关于</Link></li>
<li><Link href="/dashboard">后台</Link></li>
</ul>
</nav>
{children}
</body>
</html>
);
}
这里的 metadata 就是 SEO 的第一层:告诉搜索引擎你是谁、做什么、提供什么价值。
然后是首页和关于页:
javascript
// app/page.tsx
export default function Home() {
return <h1>Hello, World!</h1>;
}
javascript
// app/about/page.tsx
function About() {
return <h1>About Us</h1>;
}
export default About;
注意:这两个组件都没有 'use client' 声明,它们就是服务端组件。React 可以在 Node 环境里运行,把 JSX 编译成 HTML 字符串返回给浏览器。
3.3 嵌套布局
后台管理系统通常有自己的侧边栏或导航,可以建一个子布局:
javascript
// app/dashboard/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div>
<nav>后台导航栏</nav>
{children}
</div>
);
}
这样 /dashboard 和 /dashboard/settings 都会先经过全局布局,再经过后台布局,最后渲染页面内容。
文件系统就是路由器,文件夹层级就是路由层级。少写配置,多写业务。
四、服务端组件与客户端组件:水饺与水合
Next.js 把 React Server Component(RSC)带到了生产环境,这是它区别于传统 CSR 的关键。
4.1 默认都是服务端组件
在 App Router 下,没有 'use client' 的组件都是服务端组件。它们只在服务端运行,可以访问数据库、文件系统等后端资源,最终输出 HTML。
服务端组件不能使用 useState、useEffect、事件监听等浏览器特性,因为这些是给客户端准备的。
4.2 什么时候由服务端渲染,什么时候由浏览器渲染?
很多刚接触 Next.js 的同学会困惑: "我这个组件到底是在服务器上跑,还是在浏览器里跑?"
其实规则很简单,记住下面这张表就够了:
| 组件类型 | 渲染位置 | 能否使用 useState / useEffect / 事件 |
典型场景 |
|---|---|---|---|
服务端组件 (没有 'use client') |
只在服务端渲染,浏览器只负责显示静态 HTML | ❌ 不能 | 展示内容、SEO 关键部分、直接查数据库、读取文件 |
客户端组件 (文件顶部有 'use client') |
先在服务端预渲染成 HTML,再在浏览器水合(hydrate)后接管交互 | ✅ 可以 | 表单输入、点击事件、轮播图、需要状态更新的交互模块 |
API 路由 (app/api/**/route.ts) |
只在服务端执行,返回 JSON 数据,不涉及 UI 渲染 | ❌ 不涉及 | 数据接口、第三方服务对接 |
布局 / 页面 (layout.tsx / page.tsx) |
取决于内部组件:若无 'use client' 则整体服务端渲染;若有则混合渲染 |
视情况 | 页面骨架、路由容器 |
几个关键结论:
- 服务端组件是"一次性"的:它们在服务端执行完毕,生成 HTML 字符串发给浏览器,之后就不再参与任何交互。就像印刷好的报纸,内容固定,不能点击。
- 客户端组件 是"两步走":先在服务端把能渲染的部分渲染成 HTML(保证 SEO 和首屏速度),然后在浏览器里执行 JS、绑定事件、处理状态更新。所以你会发现,
'use client'组件里的代码会执行两次:一次在服务端生成静态外壳,一次在客户端水合激活。 - 动态数据获取的位置 也有讲究:服务端组件可以在服务端直接
fetch数据,数据随 HTML 一起返回;客户端组件通常在浏览器useEffect里fetch数据,爬虫看不到这些异步加载的内容。
打个比方 :
服务端先把水饺包好、冻上,送到你家;你自己烧水煮一下,就能吃了。
这个"煮"的过程,就是水合。
4.3 什么时候需要客户端组件?
当页面需要强交互时,比如表单输入、点击事件、异步请求后更新 UI,你需要在文件顶部加上:
arduino
'use client';
一个常见的误区是: "客户端组件只在浏览器渲染" 。
实际上,客户端组件也会在服务端先渲染成静态 HTML,浏览器拿到后执行水合(hydration)------挂载客户端 JS、绑定事件、激活交互。
我们对比一下:
javascript
// app/about/page.tsx ------ 服务端组件
function About() {
return <h1>About Us</h1>;
}
scss
// app/todos/page.tsx ------ 客户端组件
'use client';
import { useState, useEffect } from 'react';
export default function TodosPage() {
const [todos, setTodos] = useState([]);
const [text, setText] = useState('');
useEffect(() => {
fetch('/api/todos')
.then(res => res.json())
.then(setTodos);
}, []);
// ...
}
服务端组件负责"能被看见",客户端组件负责"能被操作"。
SEO 的关键内容,尽量放在服务端组件里;交互逻辑,用客户端组件增强。
如果不加"use client"

五、API 路由:全栈的另一半
Next.js 不仅能返回 HTML,还能返回 JSON。
在 app/api/ 目录下创建 route.ts,导出一个或多个 HTTP 方法函数即可。
我们实现一个待办事项的 API:
ini
// app/todos/types.ts
export type Todo = {
id: number;
content: string;
completed: boolean;
};
javascript
// app/api/todos/route.ts
import { type Todo } from '../../todos/types';
let todos: Todo[] = [
{ id: 1, content: '学习 App Router', 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 newTodo: Todo = {
id: +Date.now(),
content: body.content,
completed: false,
};
todos.push(newTodo);
return Response.json(newTodo);
}
文件约定同样适用:route.ts 就是接口地址。
同一个框架,既能返回 HTML,也能返回 JSON。 这就是全栈。
六、实战:待办事项全栈 Demo
把上面的碎片拼起来,我们做一个完整的待办事项页面:
- 服务端渲染页面骨架,SEO 能看到标题;
- 客户端组件负责交互:拉取列表、添加事项;
- API 路由提供数据接口。
目录结构:
markdown
app/
├── layout.tsx
├── page.tsx
├── about/
│ └── page.tsx
├── dashboard/
│ ├── layout.tsx
│ ├── page.tsx
│ └── settings/
│ └── page.tsx
├── todos/
│ ├── types.ts
│ └── page.tsx
└── api/
└── todos/
└── route.ts
app/todos/page.tsx 完整代码:
javascript
'use client';
import { useState, useEffect } from 'react';
import { type Todo } from './types';
export default function TodosPage() {
const [todos, setTodos] = useState<Todo[]>([]);
const [text, setText] = useState('');
const fetchTodos = async () => {
const res = await fetch('/api/todos');
const data: Todo[] = await res.json();
setTodos(data);
};
const handleAdd = async () => {
if (!text.trim()) return;
await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text }),
});
// 添加成功后清空输入框,并重新拉取列表
setText('');
fetchTodos();
};
useEffect(() => {
fetchTodos();
}, []);
return (
<div style={{ marginTop: '12px' }}>
<h1>待办事项</h1>
<input
value={text}
onChange={e => setText(e.target.value)}
placeholder="请输入待办事项"
/>
<button onClick={handleAdd} style={{ marginLeft: '12px' }}>
添加
</button>
<ul style={{ paddingLeft: 0, listStyle: 'none' }}>
{todos.map(item => (
<li
key={item.id}
style={{ margin: '8px 0', display: 'flex', gap: '10px' }}
>
<span
style={{
textDecoration: item.completed ? 'line-through' : 'none',
cursor: 'pointer',
}}
>
{item.content}
</span>
<button>删除</button>
</li>
))}
</ul>
</div>
);
}
运行 npm run dev,访问 /todos:
页面先由服务端渲染出标题和输入框,浏览器水合后,useEffect 请求 /api/todos 拉取数据并渲染列表。点"添加"会 POST 到 API,然后重新拉取列表。
这个渲染流程可以拆解成三步:
- 服务端渲染阶段 :
TodosPage组件在服务器上执行,生成包含标题、输入框、按钮的静态 HTML(此时todos还是空数组,列表区域为空)。 - 浏览器水合阶段 :客户端 JS 加载完成,React 接管页面,绑定输入框的
onChange、按钮的onClick等事件。 - 客户端数据请求阶段 :
useEffect在浏览器里发起fetch('/api/todos'),拿到数据后调用setTodos更新状态,React 在浏览器里重新渲染列表。
你会发现,列表数据是浏览器请求后渲染的,爬虫抓取时看不到待办事项的具体内容 。如果希望列表内容也能被搜索引擎收录,就需要在服务端组件里直接获取数据(比如用 async 服务端组件 + fetch),而不是依赖客户端 useEffect。
你可以把这段代码直接复制进项目跑起来,这就是一个最小可用的 Next.js 全栈闭环。
七、SEO 三板斧:Next.js 怎么帮你被收录?
做 SEO 其实不复杂,核心就三件事:
第一层:metadata
在 layout.tsx 或每个页面里导出 metadata:
arduino
export const metadata: Metadata = {
title: '我的全栈应用',
description: '一个 Next.js 全栈项目,SEO 友好',
keywords: 'Next.js, SEO, 全栈',
};
这对应 HTML 里的:
ini
<title>我的全栈应用</title>
<meta name="description" content="一个 Next.js 全栈项目,SEO 友好" />
<meta name="keywords" content="Next.js, SEO, 全栈" />
第二层:内容
搜索引擎喜欢高质量、原创、结构清晰的内容。你的页面要回答用户的问题,提供价值。
第三层:SSR
服务端渲染保证爬虫能拿到完整 HTML。Next.js 默认就是 SSR,所以你的内容站天然具备 SEO 基础。
对于 /post/:id 这种动态路由,Next.js 可以在服务端渲染千万篇文章,整站被收录的内容会给你的域名加权。
SEO 不是玄学,是把该给爬虫看的内容提前端上桌。
现在很多 AI 产品的官网用 Next.js 做,就是这个原因------搜索流量依然是性价比最高的获客渠道。
另外,生成式引擎优化(GEO)也在兴起,用户可能直接在豆包、Kimi 里问问题,如果你的内容出现在生成结果里,就能带来精准流量。SSR 友好的内容同样有利于被 AI 抓取和理解。
总结
Next.js 是一个 React 全栈框架,它解决了 SPA 在 SEO 上的短板:
- 文件即路由 :约定大于配置,
page.tsx就是页面,layout.tsx就是布局。 - 服务端组件:默认 SSR,组件在 Node 里渲染成 HTML,爬虫可读。
- 客户端组件 :
'use client'声明交互组件,服务端预渲染 + 客户端水合。 - API 路由 :
route.ts返回 JSON,一个项目搞定前后端。
记住这张图:
less
#root (SPA) → 爬虫看到空壳
JSX + 数据 → HTML (SSR) → 爬虫看到内容
下次再有人问你"Next.js 和 React 有什么区别?",你可以告诉他:
React 是 UI 库,Next.js 是让 React 在全栈和 SEO 上开箱即用的框架。
动手试试吧,跑一遍这个待办事项 Demo,你就掌握了 Next.js 最核心的 80%。