学完这篇你能得到一个能跑起来的 Next.js 14 App Router 项目:会建页面、会写动态路由、会在服务端取数据、会做表单提交,并且知道哪些坑会让新手卡半天。
第一步:创建项目
在终端执行:
npx create-next-app@14 my-app
交互式问卷里,App Router 那项一定选 Yes(默认就是 Yes)。其他项推荐:TypeScript 选 Yes、ESLint 选 Yes、Tailwind 选 Yes、src/ 目录选 No(App Router 下 app/ 直接放根目录更直观)、import alias 保持默认 @/*。
不想答题可以用一行参数直接生成:
npx create-next-app@14 my-app --ts --tailwind --eslint --app --no-src-dir --import-alias "@/*"
进目录跑起来:
cd my-app
npm run dev
打开 http://localhost:3000 看到默认首页就成功了。
注意:装的时候别用 create-next-app@latest,现在 latest 已经是 15/16 了,里面 params、searchParams 改成了 Promise,网上大量 14 的代码贴进去会报类型错误。入门阶段版本对齐比用最新版重要。
第二步:认识 app 目录的约定
App Router 的核心规则只有一句:文件夹决定 URL 路径,特定文件名决定角色。
app/
layout.tsx # 根布局,必须有,里面要包含 <html> 和 <body>
page.tsx # 首页,对应 /
globals.css # 全局样式
posts/
page.tsx # 对应 /posts
[id]/
page.tsx # 对应 /posts/1、/posts/2……
layout.tsx 是嵌套的:app/posts/layout.tsx 会包裹 app/posts/ 下所有页面,且切换子路由时不重新渲染,适合放侧边栏、导航。
第三步:写页面和动态路由
静态页面就是导出一个组件:
// app/posts/page.tsx
export default function PostsPage() {
return <h1>帖子列表</h1>;
}
动态段用方括号包住文件夹名,参数从 params 拿(这是 14 的写法):
// app/posts/[id]/page.tsx
export default function PostPage({ params }: { params: { id: string } }) {
return <h1>帖子 #{params.id}</h1>;
}
第四步:在服务端直接取数据
App Router 里组件默认就是服务端组件,可以直接 async,不需要 getServerSideProps:
// app/posts/page.tsx
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 }, // 60 秒后重新拉取
});
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts();
return <ul>{posts.map((p: any) => <li key={p.id}>{p.title}</li>)}</ul>;
}
好处是这段代码不会进浏览器 bundle,可以直接连数据库、读密钥。
缓存控制三种常用写法:
| 写法 | 效果 |
|---|
{ cache: 'no-store' } | 每次请求都取新数据 |
{ next: { revalidate: 60 } } | 60 秒内用缓存 |
{ next: { tags: ['posts'] } } | 配合 revalidateTag('posts') 手动失效 |
注意:14 里 fetch 默认是缓存的,很多人改完接口发现页面死活不更新,就是踩了这个。调试阶段先加 cache: 'no-store' 看是不是缓存问题,再回头设计缓存策略。
第五步:需要交互时加 'use client'
useState、useEffect、onClick 这些只能在客户端组件里用。在文件第一行加:
'use client';
import { useState } from 'react';
export default function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>{n}</button>;
}
原则是「尽量往下沉」:把交互部分拆成独立的小组件,页面主体仍然是服务端组件。
注意:'use client' 标记的组件,它 import 进来的所有子组件都会变成客户端组件。想让服务端内容穿过客户端组件,用 children 传进去,不要直接 import。
第六步:Server Actions 处理表单
14 里最实用的新能力,表单提交不用手写 API 路由:
// app/posts/new/page.tsx
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
export default function NewPost() {
async function createPost(formData: FormData) {
'use server';
const title = formData.get('title');
await fetch('https://api.example.com/posts', {
method: 'POST',
body: JSON.stringify({ title }),
});
revalidatePath('/posts');
redirect('/posts');
}
return (
<form action={createPost}>
<input name="title" required />
<button type="submit">发布</button>
</form>
);
}
'use server' 写在函数体内,这个函数就只会在服务端执行,前端拿到的只是一个引用。
第七步:补上 loading、error、not-found
这三个是文件名约定,放进对应目录就自动生效,不用写一行配置:
loading.tsx:页面加载中自动展示(基于 Suspense)
error.tsx:必须是客户端组件,接收 error 和 reset 两个 props
not-found.tsx:调用 notFound() 时展示
另外每个页面可以导出 metadata 对象:
export const metadata = { title: '帖子列表', description: '全部帖子' };
第八步:构建与部署
npm run build && npm start
build 输出的表格会列出每条路由是静态(○)还是动态(ƒ),这是排查性能问题的第一手资料。环境变量放在 .env.local,服务端用的变量不要加 NEXT_PUBLIC_ 前缀,否则会被打进浏览器。
小结
- 文件夹即路由,
page.tsx / layout.tsx / loading.tsx / error.tsx 是约定文件名
- 组件默认在服务端渲染,能直接
async 取数据;要交互才加 'use client'
fetch 默认缓存,数据不更新先查这里,用 revalidate 系列控制
- 动态路由参数 Next 14 从
params 同步取,15 以后是 Promise,抄代码要看版本
- 表单优先用 Server Actions,少写一层 API 路由
npm run build 的路由表是判断静态/动态的入口