开篇
如果你是一个 React 开发者,大概率经历过这样的场景:
- 用 Vite 搭了一个 React SPA,上线后发现搜索引擎搜不到自己的页面 ------ 爬虫拿到的是空 HTML
- 想加个后端接口存数据,又得单独起一个 Node 服务,前后端两个项目来回切
- 路由要装
react-router-dom,状态管理要装一堆库,配置文件越堆越多
Next.js 就是为了解决这些痛点而生的。
它是 React 生态的全栈应用框架 ------ 同一个项目里,既能写前端页面,也能直接写后端 JSON 接口,路由靠文件系统约定,SEO 天然友好。如今大量 AI 产品官网、企业站、内容平台都基于 Next.js 构建。
先做一个概念区分,很多新手容易搞混这三个名字:
- Next.js:React 全栈框架(页面 + 接口一体化)
- Nuxt.js:Vue 全栈框架,对应 Vue 技术栈
- NestJS:纯 Node 后端框架,不负责页面渲染
本文基于 App Router 模式,从核心概念到完整 Todo 全栈实战,带你系统入门 Next.js。
一、核心渲染模式:CSR 与 SSR
理解 Next.js 之前,先搞懂两种渲染模式的本质区别 ------组件到底在哪里生成 HTML。
1.1 CSR(客户端渲染)
这是传统 Vite + React SPA 的模式。服务端只返回一个几乎空白的 HTML 文件和 JS 包,浏览器下载 JS 后在本地执行,动态生成 DOM。
css
浏览器请求 → 拿到空 HTML + JS → 浏览器执行 JS → 生成 DOM → 用户看到内容
缺点很明显:
- 搜索引擎爬虫拿到的是空页面,SEO 表现差
- 首屏加载有白屏时间,用户体验差
1.2 SSR(服务端渲染)
这是 Next.js 的核心能力。组件在服务端(Node 环境)就执行完成,生成完整的 HTML 字符串返回给浏览器。
css
浏览器请求 → 服务端执行组件生成完整 HTML → 浏览器直接渲染 → 用户看到内容
优势:
- 搜索引擎直接拿到完整内容,SEO 友好
- 首屏更快,用户看到内容的时间更短
1.3 一张表看懂区别
表格
| 对比项 | CSR 客户端渲染 | SSR 服务端渲染 |
|---|---|---|
| 渲染位置 | 浏览器 | Node 服务端 |
| 爬虫拿到的 HTML | 空壳子 | 完整内容 |
| SEO 表现 | 差 | 优秀 |
| 首屏速度 | 较慢(等 JS 执行) | 快 |
| 代表 | Vite + React SPA | Next.js |
1.4 SEO 的三层优化逻辑
做好 SEO 本质是让搜索引擎更好地理解你的页面,分三层:
-
第一层:头部元信息
title:页面标题,告诉搜索引擎 "你是谁、做什么的"description:页面描述,告诉搜索引擎 "有什么价值"keywords:关键词- 对应 HTML 的
<head>标签
-
第二层:正文内容
<body>里的真实文本内容,是爬虫抓取的核心- 用户为什么来你的页面,就靠这些内容
-
第三层:渲染模式
- 用 SSR 保证服务端输出完整 HTML,而不是空壳
在 Next.js 中,通过导出 metadata 对象即可配置第一层元信息:
typescript
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "我的待办清单 - 高效任务管理",
description: "基于 Next.js 开发的在线待办事项管理工具,支持新增、删除、状态切换",
keywords: "待办事项,任务管理,Next.js,React",
};
二、App Router:文件即路由,无需手动配置
Next.js 13 之后主推的 App Router 模式,用文件系统约定 替代了传统的路由配置代码,不需要安装 react-router-dom。
2.1 核心约定
- 文件夹 = 路由路径 :
app/about/page.tsx对应访问地址/about page.tsx= 页面入口 :一个文件夹下必须有page.tsx,该路由才对外可访问layout.tsx= 共享布局 :定义该层级及所有子路由的公共布局(导航、页脚等),children插槽渲染子页面内容
2.2 典型目录结构
python
app/
├── layout.tsx # 根布局,所有页面共享
├── page.tsx # 首页,对应 /
├── about/
│ └── page.tsx # 关于页,对应 /about
├── dashboard/
│ └── page.tsx # 后台页,对应 /dashboard
├── todos/
│ ├── page.tsx # 待办页,对应 /todos
│ └── type.ts # Todo 类型定义
└── api/
└── todos/
└── route.ts # 后端接口,对应 /api/todos
2.3 根布局示例
app/layout.tsx 是整个应用的外壳,所有页面都会被包裹在里面:
javascript
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,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="zh-CN"
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<body className="min-h-screen flex flex-col">
<nav>
<ul style={{ display: "flex", gap: 20, listStyle: "none", padding: 0 }}>
<li><Link href="/" style={{ color: "#df1721" }}>首页</Link></li>
<li><Link href="/about" style={{ color: "#df1721" }}>关于</Link></li>
<li><Link href="/dashboard" style={{ color: "#df1721" }}>后台管理</Link></li>
</ul>
</nav>
{/* 当前路由的 page.tsx 内容会渲染到这里 */}
{children}
</body>
</html>
);
}
注意:
Link组件从next/link导入,是 Next.js 内置的路由跳转组件,支持预加载,性能优于原生<a>标签。
三、服务端组件 vs 客户端组件
App Router 下,组件默认是服务端组件(RSC) ,直接在服务端渲染,不会给浏览器发送多余 JS。但如果组件需要交互(点击、输入、状态管理),就必须标记为客户端组件。
3.1 使用规则
- 默认不写:服务端组件,适合静态内容展示,SEO 友好
- 顶部加
'use client':客户端组件,支持useState、useEffect、事件绑定等浏览器交互
3.2 快速判断表
表格
| 场景 | 组件类型 |
|---|---|
| 纯文本展示、标题、段落 | 服务端组件(默认) |
| 按钮点击、表单输入、弹窗控制 | 客户端组件(加 'use client') |
| 使用 React Hooks(useState/useEffect 等) | 客户端组件(加 'use client') |
| 需要访问浏览器 API(window/document) | 客户端组件(加 'use client') |
3.3 一个常见误区
很多人以为 " 加了 'use client' 就完全在浏览器渲染,服务端什么都不做 "。其实不是:
- 静态文字部分仍然会在服务端渲染成 HTML(保证 SEO)
- 交互逻辑(事件绑定、状态管理)在浏览器中执行
- 这是一种混合渲染模式,兼顾 SEO 和交互能力
四、全栈核心:Route Handler 编写后端接口
Next.js 最强大的地方在于:同一个项目里直接写后端接口,不需要额外起 Node 服务。
4.1 约定规则
app/api/xxx/route.ts文件定义接口- 导出的函数名就是 HTTP 方法名:
GET、POST、PUT、DELETE等 - 使用 Web 标准的
Request和ResponseAPI
4.2 完整接口示例:Todo 待办 API
先定义类型 app/todos/type.ts:
typescript
export interface Todo {
id: number;
title: string;
completed: boolean;
}
编写接口 app/api/todos/route.ts:
javascript
import { Todo } from "../../todos/type";
// 强制动态渲染,避免接口缓存导致数据不更新
export const dynamic = "force-dynamic";
// 内存模拟数据库(开发演示用,正式项目请用数据库)
let todos: Todo[] = [
{ id: 1, title: "学习 AppRouter 路由规则", completed: true },
{ id: 2, title: "开发 Next.js 个人官网", completed: false },
];
// GET /api/todos ------ 获取待办列表
export async function GET() {
return Response.json(todos);
}
// POST /api/todos ------ 新增待办
export async function POST(req: Request) {
// 解析请求体 JSON
const body = await req.json();
const newTodo: Todo = {
id: Date.now(),
title: body.title,
completed: false,
};
todos.push(newTodo);
// 201 表示资源创建成功
return Response.json(newTodo, { status: 201 });
}
4.3 关键细节说明
-
export const dynamic = "force-dynamic"- 强制接口每次都重新执行代码
- 避免 Next.js 静态缓存导致 "新增了数据但 GET 还是返回旧列表" 的问题
- 这是新手最容易踩的坑之一
-
await req.json()- 读取 HTTP 请求体并解析为 JS 对象
- 必须加
await,否则拿到的是 Promise 对象 - 前端发送时必须设置
Content-Type: application/json请求头,否则解析失败
-
Response.json()- 返回 JSON 格式响应
- 第二个参数可以传状态码,如
{ status: 201 }
-
内存数组的局限性
let todos: Todo[] = [...]只是开发演示用- 服务器重启后数据全部丢失
- 正式项目必须接入数据库(如 PostgreSQL + Prisma)
五、完整实战:Todo 待办页面对接接口
现在我们写一个前端页面,调用上面的接口,实现待办列表展示和新增功能。
5.1 页面代码 app/todos/page.tsx
javascript
"use client";
import { useState, useEffect } from "react";
import { Todo } from "../todos/type";
export default function TodosPage() {
const [todos, setTodos] = useState<Todo[]>([]);
const [text, setText] = useState("");
// 获取待办列表
const fetchTodos = async () => {
const res = await fetch("/api/todos", { cache: "no-store" });
const data: Todo[] = await res.json();
setTodos(data);
};
// 页面加载时获取列表
useEffect(() => {
fetchTodos();
}, []);
// 新增待办
const handleAdd = async () => {
if (!text.trim()) return;
const res = await fetch("/api/todos", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ title: text }),
});
const newItem = await res.json();
// 直接追加到本地状态,避免额外请求
setTodos((prev) => [...prev, newItem]);
setText("");
};
return (
<div style={{ maxWidth: 600, margin: "40px auto" }}>
<h1>待办事项</h1>
<div style={{ marginBottom: 20, display: "flex", gap: 10 }}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="请输入新的待办任务"
style={{ flex: 1, padding: "8px 12px" }}
/>
<button onClick={handleAdd} style={{ padding: "8px 20px" }}>
添加
</button>
</div>
<ul style={{ listStyle: "none", padding: 0 }}>
{todos.map((item) => (
<li
key={item.id}
style={{
padding: "12px 0",
borderBottom: "1px solid #eee",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span
style={{
textDecoration: item.completed ? "line-through" : "none",
cursor: "pointer",
}}
>
{item.title}
</span>
<button>删除</button>
</li>
))}
</ul>
</div>
);
}
5.2 关键细节拆解
① "use client" 必须加
因为用到了 useState、useEffect 和点击事件,这些都是浏览器侧能力,必须在文件顶部标记 'use client',否则会报错。
② GET 请求加 cache: "no-store"
ini
const res = await fetch("/api/todos", { cache: "no-store" });
禁用浏览器缓存,确保每次都拿到服务器最新数据。不加的话,新增完再 GET 可能拿到缓存的旧列表。
③ POST 请求必须设置请求头
css
headers: {
"Content-Type": "application/json",
}
告诉后端 "我发的是 JSON 格式",后端 req.json() 才能正确解析。用 axios 的话会自动加这个头,但原生 fetch 必须手动写。
④ 状态更新策略:直接追加,不再发 GET
ini
const newItem = await res.json();
setTodos((prev) => [...prev, newItem]);
POST 接口返回了刚创建的新 todo 对象,前端直接把它追加到本地 state,省去一次 GET 请求,也彻底避开了缓存坑。
这里有个常见疑问:"这不就是添加了两次吗?一次后端一次前端?" 不是的。后端
todos.push()是真正写入数据;前端setTodos只是更新界面显示,让用户立刻看到新数据。数据的权威来源始终是后端。
5.3 另一种写法:新增后重新拉取完整列表
如果你觉得 "直接追加" 不够严谨,也可以新增完重新调用 fetchTodos() 拉取完整列表:
javascript
const handleAdd = async () => {
if (!text.trim()) return;
await fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: text }),
});
// 重新拉取完整列表
await fetchTodos();
setText("");
};
这种方式逻辑更直白,但要确保:
- 接口端加了
export const dynamic = "force-dynamic" - GET 请求加了
cache: "no-store"
两者缺一不可,否则会出现 "新增了但页面不更新,必须手动 F5" 的问题。
六、创建项目与启动
6.1 初始化项目
lua
npx create-next-app@latest
默认配置勾选:
- TypeScript ✔
- Tailwind CSS ✔
- ESLint ✔
- App Router ✔
6.2 常用命令
arduino
npm run dev # 启动开发服务器(默认 http://localhost:3000)
npm run build # 构建生产版本
npm run start # 运行生产构建
npm run lint # 代码检查
七、新手常见踩坑汇总
表格
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 500 Internal Server Error | 接口代码报错,看 VSCode 终端 | 检查 await req.json() 是否加了 await、是否 return 了 Response |
| 新增后页面不更新,要手动 F5 | GET 接口被缓存 | 接口加 dynamic = "force-dynamic",fetch 加 cache: "no-store" |
| Objects are not valid as a React child | 把对象直接渲染了 | 检查 {item} 是否写成了 {item.title} |
'use client' 加了还报错 |
加在了服务端组件调用的子组件里没生效 | 确保需要交互的那个组件文件顶部加了 |
| ESLint 报 indent 缩进错误 | 配置文件缩进规则和实际不一致 | 运行 npx eslint . --fix 自动修复,或调整 indent 规则 |
| fetch POST 后端解析不到数据 | 没设 Content-Type 请求头 |
加上 headers: { "Content-Type": "application/json" } |
八、总结与下一步
Next.js 不是简单的 "React 加强版",而是面向 React 生态的全栈解决方案:
- 用 App Router 实现了文件系统路由,告别路由配置
- 用服务端组件天然解决 SEO 和首屏性能问题
- 用 Route Handler 让前端开发者直接写后端接口,实现真正的全栈开发
掌握这些基础后,下一步可以继续深入:
- Server Actions:更简单的服务端数据操作,不用手动写 fetch
- 动态路由
[id]:实现详情页,如/todos/1 - 数据缓存与 revalidate:优化接口性能,平衡实时性和性能
- 接入数据库:用 Prisma + PostgreSQL 替代内存数组,实现真正的数据持久化
- 中间件 middleware.ts:实现登录鉴权、路由重定向等