《TypeScript编程实战》12.2 Next.js App Router 类型(Server Actions / Route Handlers)

Next.js App Router 用一份 TypeScript 同时描述服务端与客户端两种运行环境。本节讲清 params/searchParams 的异步化、Route Handlers 的入参校验、Server Actions 的 use server 约束与序列化红线、useActionState 与 revalidate 的类型,以及 middleware 的盲区。

本节目标:掌握 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.headersHeaders完全不可信

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 是 PromiseNext 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 数据流类型 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes