Next.js App Router 全栈实战:从 0 到 1 写一个 Todo 应用,前端后端一个项目搞定

开篇

如果你是一个 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 本质是让搜索引擎更好地理解你的页面,分三层:

  1. 第一层:头部元信息

    • title:页面标题,告诉搜索引擎 "你是谁、做什么的"
    • description:页面描述,告诉搜索引擎 "有什么价值"
    • keywords:关键词
    • 对应 HTML 的 <head> 标签
  2. 第二层:正文内容

    • <body> 里的真实文本内容,是爬虫抓取的核心
    • 用户为什么来你的页面,就靠这些内容
  3. 第三层:渲染模式

    • 用 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' :客户端组件,支持 useStateuseEffect、事件绑定等浏览器交互

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 方法名:GETPOSTPUTDELETE
  • 使用 Web 标准的 RequestResponse API

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 关键细节说明

  1. export const dynamic = "force-dynamic"

    • 强制接口每次都重新执行代码
    • 避免 Next.js 静态缓存导致 "新增了数据但 GET 还是返回旧列表" 的问题
    • 这是新手最容易踩的坑之一
  2. await req.json()

    • 读取 HTTP 请求体并解析为 JS 对象
    • 必须加 await,否则拿到的是 Promise 对象
    • 前端发送时必须设置 Content-Type: application/json 请求头,否则解析失败
  3. Response.json()

    • 返回 JSON 格式响应
    • 第二个参数可以传状态码,如 { status: 201 }
  4. 内存数组的局限性

    • 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" 必须加

因为用到了 useStateuseEffect 和点击事件,这些都是浏览器侧能力,必须在文件顶部标记 '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:实现登录鉴权、路由重定向等
相关推荐
计算机魔术师1 小时前
英伟达预计 2028 财年营收同比增 70%,黄仁勋称实际需求远高于此
前端
掘金酱1 小时前
🔥 AI 时代,Token 就是你的数字燃料!晒账单,赢好礼!
前端·人工智能·ai编程
计算机魔术师1 小时前
Warp用Claude搭自我改进智能体
前端
计算机魔术师2 小时前
英伟达Q2营收翻倍,黄仁勋称实际需求远超70%指引
前端
计算机魔术师2 小时前
英伟达129亿美元收购Hugging Face
前端
Shinner欣儿2 小时前
React18 和 19 新特性结合看
前端·react.js
进哥AI研习社2 小时前
图片优化全链路——AVIF/WebP 自适应与懒加载策略
next.js·图片优化·懒加载·avif·lcp·模糊占位·cdn 加速
Java小卷2 小时前
低代码表单联动配置:从节点驱动到数据驱动
前端·低代码
DianSan_ERP3 小时前
WMS接入电商平台自动化履约实战:一张订单从平台到出库的接口时序设计
java·前端·网络·数据库·安全·架构·自动化