
字节笔记本
2026年10月7日 · 约 10 分钟读完
表单和路由不要各写一套校验
表单的 action 可以直接指向一个带 'use server' 的函数。另一条路是在 app/api 里写 POST,浏览器用 fetch 把 JSON 送过去。两条都能在服务器上创建一条记录。差别是谁发起调用、数据从哪来、页面数据靠谁刷新。

表单把字段放进 FormData
对话里的页面是客户端组件,文件顶上 'use client'。输入框的 name 是 title,表单的 action 是 createPost。这个函数放在 actions.ts,文件顶上是 'use server'。它接收 FormData,用 formData.get('title') 取出标题,然后 revalidatePath('/posts')。
export async function createPost(formData: FormData) {
const title = formData.get('title')
revalidatePath('/posts')
}没有 fetch,也没有自己拼 Content-Type。提交由浏览器按表单去做。函数在服务器上跑,所以对话里说它可以碰数据库。样本只把标题打到日志,没有真正写入。接数据库时写在这个函数里,不要再让客户端拿着连接串。
revalidatePath('/posts') 让这份路径上的缓存失效,下次渲染会重新取数据。它不负责把输入框清掉。输入框的 value 还绑在 useState 上,清掉要在客户端做。样本没有在 action 返回之后 setTitle('')。
路由用 Request,客户端用 fetch
对应的路由在 app/api/posts/route.ts。导出 POST,参数是 Request。await request.json() 得到 { title },成功时 NextResponse.json({ message: 'Post created successfully' }, { status: 201 })。
客户端表单改成 onSubmit。preventDefault 之后:
await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
})response.ok 才清空输入。状态码不是 2xx 时,样本只是不打印成功,没有把错误显示出来。这条路要自己处理失败。
路由不限于这个 Next 页面。任何能发 HTTP 的客户端都能调用它。Server Action 是给这个应用里的组件调用的,不是一条公开的 URL 合同。外部系统、别的服务要创建同一条记录时,走路由,不要去调那个只在打包结果里的函数。
点赞不是再写一个表单
对话里问过是不是只有表单。样本又给了一个按钮。客户端 handleLike 调用 likePost(params.id),把返回的数字放进 setLikes。函数同样在 'use server' 文件里。它用 cookies().get('userId')?.value 读用户,再返回一个数。
返回的数是 Math.floor(Math.random() * 100) + 1。这是占位,不是读出来的点赞总数。日志会打用户和文章 id。接上存储之后,应该先查这个用户是否已经点过,再写库,再返回库里的计数。直接把随机数显示在按钮旁,刷新前后对不上。
cookies() 只能在服务器函数里这么读。客户端组件不要自己解析这份 cookie 来当权限。按钮的 onClick 只负责调用并展示返回值。
两条路的安全边界也不同。对话把 CSRF 保护算在 Server Action 一侧,路由侧要自己做。样本没有展示校验码或来源头,所以路由如果对公网开放,不能只靠“前端是自己写的”。至少区分登录态,不要让匿名 POST 创建内容。

把两次创建的字段对齐
Server Action 从 formData.get('title') 拿标题。路由从 request.json() 拿 title。两边的字段名都是 title。表单输入框必须有 name="title",JSON 请求体必须有同名的键。输入框只有 value 没有 name 时,FormData 里没有标题,函数仍会跑,日志里是空的。JSON 那条若漏了 Content-Type,request.json() 会失败,样本没有写 try。失败要返回明确的状态,而不是让框架打出一页堆栈。
createPost 末尾 revalidatePath('/posts')。路径字符串要和页面实际路径一致。写错成别的路径,列表不会更新,函数看起来却成功了。路由那条返回 201 和 { message: 'Post created successfully' }。客户端只判断 response.ok,不读取 message。要在界面上显示这句话,得再 response.json()。样本在成功后 setTitle(''),失败时不清空,方便人改完再提交。
点赞函数的参数是 postId: string,返回 Promise<number>。cookies().get('userId') 可能没有值,样本用了可选链,userId 可以是 undefined。日志仍会打出来。没有用户 id 时不应增加计数。随机数 Math.floor(Math.random() * 100) + 1 每次都在 1 到 100 之间,和数据库无关。替换成真实计数之前,界面上的数字只说明函数被调用了。
按钮在客户端组件里,函数在服务器文件里。文件顶上的 'use client' 和 'use server' 不要写进同一个文件。调用关系是导入:页面导入 createPost 或 likePost。路由文件不导入这两个函数也可以独立工作。若两套都要留,抽取真正写库的那一个函数,让 action 和 POST 都调用它。否则标题校验会只存在于其中一条。
外部系统只能打到路由。它们没有办法提交这个 React 表单。把 Server Action 当成对外接口,调用方无法用普通 HTTP 客户端按 URL 调用。需要 webhook 或别的服务时,从一开始就写 app/api 下的 POST。
谁来调用,决定写哪一种
页面内的表单用 'use server' 函数,字段从 FormData 取,更新缓存用 revalidatePath。要给外部调用,就在 app/api/.../route.ts 导出 POST,客户端 fetch JSON,自己看 response.ok。按钮也可以调服务器函数,不必包一层表单。点赞样本里的随机数不能当真实计数。路由和函数不要各写一套互不调用的创建逻辑,否则同一标题会有两种入口、两种校验。
表单那条文件分两处:页面 'use client',actions.ts 第一行 'use server'。输入框 name 和 value 都要在,name 给 FormData,value 给 React 状态。路由那条文件是 app/api/posts/route.ts,导出的函数名是 POST,成功状态 201,正文是 message。客户端 fetch 的方法、头和 JSON.stringify({ title }) 必须和路由的 request.json() 对上。点赞在 app/posts/[id]/actions.ts,参数是文章 id 字符串,cookie 名是 userId。没有这枚 cookie 时不要增加计数。返回的 1 到 100 的随机数只证明函数执行了,接数据库之后改为返回库里的数,并先判断这个用户是否已经点过。revalidatePath 的参数用 '/posts',和页面路径一致。两套创建最后都调用同一个写库函数,校验只写一次。对外的 webhook 走路由,不走 Server Action。
两条创建路径把同一个标题字段走完,差别只在运输方式。表单把字段放进表单数据,所以输入框必须有名字。路由从请求正文的对象里取同名字段。要声明正文类型是结构化数据,漏了类型时解析会失败。这时要返回明确的错误状态,而不是堆栈页面。刷新缓存时写的路径必须就是文章列表那一条。写错则函数看似成功,页面仍旧。浏览器那一侧只有在响应成功时才清空输入。点赞读取的饼干名字固定,没有饼干就不增加计数。返回的整数若来自随机,范围只说明函数跑过,不能展示成真实总数。客户端指令和服务端指令分开放文件。真正写库的逻辑只留一份,两个入口都调用它。外部系统没有表单可提交,只能打到接口目录下的创建路由。成功时用创建成功的状态码。同一套校验不要在两个入口各写一遍。表单和路由不要各写一套互不相通的校验。否则同一个标题会在一边被拒绝,另一边被收下。写库函数只留一份,入口可以有两个,校验不能有两份。刷新路径写错时,人会以为已经创建成功。外部系统只认路由这一条入口就可以。



