本节目标:掌握 App Router 里「服务端 / 客户端」这条边界的类型规则。读完你能正确写出 Next.js 15 的
params异步类型,能用 Zod 把 Route Handler 的入参从any收窄,能设计 Server Action 的签名与返回类型,能识别 React Flight 序列化的红线,并知道revalidateTag、middleware.matcher这类「类型管不到」的地方该靠什么兜住。
12.2 Next.js App Router 类型(Server Actions / Route Handlers)
上一节的 Vue 组件只有一种运行环境。App Router 不同:同一个 app/ 目录下,默认是服务端组件(在 Node 或 Edge 上执行),标了 'use client' 的才是客户端组件。一份 tsconfig 同时检查这两种代码,于是类型系统的任务变成「守住这条边界」——哪些值能跨过去、哪些函数能当 RPC 调用、哪些错误必须在这里就拦住。
12.2.1 一条边界,两种运行环境
| 概念 | 运行位置 | 能否 async | 能否用 useState |
|---|---|---|---|
| 服务端组件(默认) | 服务器 | 能 | 不能 |
客户端组件('use client') | 浏览器 + 服务端预渲染 | 不能 | 能 |
Route Handler(route.ts) | 服务器 | 能 | 不适用 |
Server Action('use server') | 服务器 | 能 | 不适用 |
类型系统无法表达「这段代码在哪跑」,它只能表达值的形状。所以边界安全靠两条:一条是 import 'server-only' 这种编译期标记(见 12.3),另一条是跨边界值必须可序列化这条硬规则。
共享类型的做法是单独建一个 lib/types.ts,两边都 import,但它必须只含 JSON 可表示的结构:
// lib/types.ts —— 服务端与客户端共用的边界类型
export interface Post {
id: string;
title: string;
publishedAt: string; // 用 ISO 字符串,不用 Date
tags: string[];
}
12.2.2 params 与 searchParams:Next 15 起是 Promise
这是升级 Next 15 后最集中的一类报错。params 与 searchParams 从同步对象变成了 Promise:
// app/posts/[slug]/page.tsx
type PageProps = {
params: Promise<{ slug: string }>;
searchParams: Promise<{ page?: string; q?: string }>;
};
export default async function PostPage({ params, searchParams }: PageProps) {
const { slug } = await params;
const { page = '1', q } = await searchParams;
return <article data-slug={slug} data-page={page} data-q={q} />;
}
Next 14 里它们是 params: { slug: string } 这样的同步对象,15 改成 Promise 是为了支持流式渲染下的部分预渲染。类型不会自动迁移,但会立刻报错:没 await 时 slug 是 Promise<string>,用在 JSX 里马上报 Type 'Promise<string>' is not assignable to type 'ReactNode'。这种「升级即报错」反而是好事,比静默返回 [object Promise] 强得多。
generateMetadata 与 generateStaticParams 复用同一份类型:
import type { Metadata } from 'next';
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return { title: post.title, description: post.title };
}
export async function generateStaticParams() {
const posts = await fetchPosts();
return posts.map((p) => ({ slug: p.id })); // 形状必须与 params 对应
}
generateStaticParams 的返回类型被 Next 的类型约束检查:返回 { slug: string }[] 才能喂给 [slug] 路由,写成 { id: string }[] 会在构建时报错。
12.2.3 Route Handlers:路径参数永远是字符串
app/api/posts/[id]/route.ts 导出 GET / POST 等具名函数,签名是 (req, ctx) => Response | Promise<Response>:
// app/api/posts/[id]/route.ts
import { NextResponse, type NextRequest } from 'next/server';
import { z } from 'zod';
const Params = z.object({ id: z.string().uuid() });
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const parsed = Params.safeParse(await ctx.params);
if (!parsed.success) {
return NextResponse.json({ error: 'bad id' }, { status: 400 });
}
const post = await db.post.findUnique({ where: { id: parsed.data.id } });
if (!post) return NextResponse.json({ error: 'not found' }, { status: 404 });
return NextResponse.json(post);
}
关键认知:路径参数在运行时永远是字符串,TS 类型只是形状描述,不校验格式。z.string().uuid() 这一步才是真正的校验。这张表值得贴在显示器上:
| 入参位置 | 类型来源 | 运行时可信度 |
|---|---|---|
params | 文件路径静态推导 | 形状可信,值需校验 |
searchParams | 无约束 | 完全不可信 |
req.json() | 返回 any | 完全不可信 |
req.headers | Headers | 完全不可信 |
req.json() 的返回类型是 Promise<any>,所以下面第一行是自欺欺人:
const body = (await req.json()) as CreatePostInput; // ✗ 断言,零校验
const body = CreatePost.parse(await req.json()); // ✓ 失败即抛错,交给错误边界
同理,NextResponse.json(obj) 的参数是 any,返回类型也不会帮你校验 obj 是否符合某个 schema。要保证响应体类型正确,只能靠「先 parse 再 json」的纪律。
12.2.4 Server Actions:'use server' 是安全边界
Server Action 的本质是把一个服务端函数编译成可被客户端调用的 RPC 端点:
// app/posts/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { db } from '@/lib/db';
const CreatePost = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1),
});
export type ActionState = {
ok: boolean;
message?: string;
fieldErrors?: Record<string, string[]>;
};
export async function createPost(_prev: ActionState, formData: FormData): Promise<ActionState> {
const parsed = CreatePost.safeParse({
title: formData.get('title'),
body: formData.get('body'),
});
if (!parsed.success) {
return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors };
}
await db.post.create({ data: parsed.data });
revalidatePath('/posts');
return { ok: true };
}
三条规则必须记住。
一、'use server' 必须是文件第一条语句,在 import 之前。写在 import 之后会被当成普通字符串,函数退化成普通异步函数,客户端调用时报「找不到 action」。
二、文件里所有导出都是公开端点。 不只是被 action= 引用的那些——任何 export 出来的函数都能被构造请求调用。所以不要在这个文件里导出工具函数、常量或内部 helper:
// ✗ 这个常量也会被当成 action 导出
export const PAGE_SIZE = 20;
// ✓ 移到 lib/constants.ts
这是安全边界,不是代码风格。放在这里的函数要自己做完鉴权与输入校验,不能假设调用方是自家表单。
三、参数与返回值必须可序列化(见 12.2.6)。FormData 是允许的,这正是它作为首选入参的原因。
12.2.5 useActionState:三元组与二元签名
客户端侧用 useActionState 接住 action 的返回值:
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { createPost, type ActionState } from './actions';
export function PostForm() {
const [state, formAction, pending] = useActionState<ActionState, FormData>(
createPost,
{ ok: false },
);
return (
<form action={formAction}>
<input name="title" />
{state.fieldErrors?.title && <p>{state.fieldErrors.title[0]}</p>}
<SubmitButton />
{pending && <span>提交中…</span>}
</form>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>保存</button>;
}
返回的三元组类型是 [state: ActionState, dispatch: (payload: FormData) => void, isPending: boolean]。React 19 起 useActionState 从 react 导入;旧的 useFormState 来自 react-dom,已弃用,IDE 里会给出 'useFormState' is deprecated。
最常见的类型错误是 action 签名少写了 _prev:
export async function createPost(formData: FormData): Promise<ActionState> { /* ... */ }
// ✗ Type '(formData: FormData) => Promise<ActionState>' is not assignable to
// parameter of type '(state: ActionState, payload: FormData) => ...'
因为 useActionState 要求二元签名 (prevState, formData)。参数用不上时命名成 _prev(下划线前缀可绕过 noUnusedParameters)。
12.2.6 序列化红线:React Flight 能过什么
跨边界的值由 React Flight 序列化,支持范围比 JSON 略宽,但远窄于 JS 对象:
| 类别 | 能过边界 | 说明 |
|---|---|---|
| 字符串 / 数字 / 布尔 / null | 是 | 基本类型 |
| 数组 / 纯对象 | 是 | 原型必须是 Object.prototype |
Date / Map / Set | 是 | 但客户端拿到的是新实例 |
FormData / Promise / React 元素 | 是 | 流式传输 |
类实例 / 函数 / Symbol / WeakMap | 否 | 运行时抛错 |
危险之处在于类型检查拦不住:把 ORM 实体直接返回,类型上完全合法,运行时才炸:
// ✗ 类型通过,运行时抛错
export async function getPost(id: string): Promise<PostEntity> {
return new PostEntity(id);
}
// Error: Only plain objects, and a few built-ins, can be passed to Client Components
正确做法是在边界处转成 DTO,让「跨界」这件事显式发生:
export async function getPost(id: string): Promise<Post> {
const row = await db.post.findUniqueOrThrow({ where: { id } });
return {
id: row.id,
title: row.title,
publishedAt: row.publishedAt.toISOString(),
tags: row.tags,
};
}
把 Date 序列化成 ISO 字符串而不是直接传 Date,能顺手解决下一节的时区问题:string 的语义明确,Date 跨边界后是重新构造的实例,时区偏移容易被忽略。
12.2.7 redirect / notFound 返回 never
redirect() 与 notFound() 的返回类型是 never,所以它们之后不需要 return,调用方也不必写 else:
import { redirect, notFound } from 'next/navigation';
export default async function Page({ params }: PageProps) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound(); // never,后续代码不会执行
return <article>{post.title}</article>;
}
redirect() 靠抛异常实现,因此它绝不能待在会捕获它的 try 里:
try {
await createPost(data);
redirect('/posts'); // ✗ 抛出的跳转信号被 catch 吞掉
} catch (e) {
return { ok: false, message: 'failed' };
}
正确写法是把业务放进 try,redirect 放到 try 之后;确实要在 catch 里跳转时,先判断是不是跳转信号再决定是否 rethrow(Next 提供了 isRedirectError 之类的判定辅助)。
12.2.8 revalidate:标签是裸字符串
import { revalidatePath, revalidateTag } from 'next/cache';
// action 内
revalidateTag('posts'); // 精确失效某个标签
revalidatePath('/posts'); // 按路径失效
类型上的注意点:tag 是裸字符串,没有任何类型约束。写错不会报错,只会「缓存不失效」——表现为「提交成功但页面还是旧数据」,排查成本很高。工程上要把它收拢成常量表,让拼写错误变成引用错误:
export const CacheTags = {
posts: 'posts',
post: (id: string) => `post:${id}`,
} as const;
动态 tag 用函数封装,返回值类型由 as const 固定,改一处全局可控。这套标签机制与下一节的 ISR 数据流是同一个东西,届时会再展开。
12.2.9 middleware 与 matcher 的类型盲区
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
export function middleware(req: NextRequest) {
const token = req.cookies.get('session')?.value;
if (!token && req.nextUrl.pathname.startsWith('/dashboard')) {
const url = req.nextUrl.clone();
url.pathname = '/login';
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = { matcher: ['/dashboard/:path*'] };
config.matcher 是字符串数组,没有类型级别的路径校验:正则写错不报错,只让中间件静默不生效。这是排查「鉴权没拦住」时的第一现场。另外 middleware 跑在 Edge Runtime,不能用 fs、child_process 等 Node API——类型上不一定报错,但构建会失败,所以要在 CI 里跑一次 next build 才能暴露。
12.2.10 常见坑速查
| 症状 | 原因 | 修法 |
|---|---|---|
params.slug 是 Promise | Next 15 起 params 异步化 | await params |
'use server' 文件里的常量被当端点 | 该文件所有导出都是 RPC | 常量移到别的文件 |
| action 类型报参数个数不匹配 | 签名必须是 (prev, formData) | 补 _prev 形参 |
| 类实例传给客户端组件报错 | Flight 不可序列化 | 边界处转 DTO |
redirect 不生效 | 被 try/catch 吞掉 | 移到 try 外 |
revalidateTag 无效果 | tag 字符串拼错 | 用常量表 |
useActionState 找不到 | 还在从 react-dom 导入旧名 | 改从 react 导入 |
12.2.11 与本书其它章节的衔接
Server Actions 与 Route Handlers 解决的是「应用内契约」;跨服务、跨团队的契约见 《TypeScript编程实战》16.1 tRPC 端到端类型安全 与 《TypeScript编程实战》16.2 OpenAPI / GraphQL Codegen ,版本演进规则见 《TypeScript编程实战》16.3 契约版本演进与兼容 。校验一律用 Zod,与表单侧共用同一份 schema,见 《TypeScript编程实战》13.1 React Hook Form + Zod 与 《TypeScript编程实战》13.2 表单类型推导与错误映射 。服务端数据访问层见 《TypeScript编程实战》7.1 Prisma schema 与类型生成 与 《TypeScript编程实战》7.2 Drizzle 的 SQL 式类型推导 。服务端抛出的错误如何被边界接住,见 《TypeScript编程实战》3.2 全局错误边界与未捕获异常 。
站内延伸阅读:Next.js App Router 结构 、Route Handlers 、Server Actions 、React Server Components 、App Router 深度实践 、React 全栈类型安全实践 。
小结
App Router 的类型问题可以浓缩成一句话:类型能描述形状,但描述不了运行位置,所以边界的正确性必须靠显式约束。
四条规则值得带走。第一,params 与 searchParams 在 Next 15 起是 Promise,必须 await;路径参数在运行时永远是字符串,格式校验只能靠 Zod 之类的运行时校验器。第二,'use server' 文件里每一个导出都是公开端点,别把工具函数和常量放进去;action 的签名是二元的 (prev, formData),与 useActionState 配套。第三,跨边界的值必须可序列化,类实例与函数是红线,类型检查拦不住,所以要在边界处显式转 DTO,并用 string 而非 Date 表达时间。第四,revalidateTag 的标签与 middleware.matcher 的正则都在类型系统之外,必须用常量表和构建期验证兜住。
下一节继续沿着「数据」这条线往下走:当渲染发生在构建时(SSG / ISR)而不是每次请求时,类型不变但数据的新鲜度变了。这正是类型系统最无力、也最容易出事的地方。
阅读导航:上一节:12.1 Vue 3 组合式 API 类型 · 下一节:12.3 SSR·ISR 数据流类型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。