Next.js Route Handler 写 API:缓存、动态参数与流式返回全踩一遍
很多人从 Pages Router 的pages/api迁到 App Router 后,第一反应是「Route Handler 不就是换了个文件名吗」,结果上线才发现:GET 接口返回的数据死活不更新、动态路由参数取不到、想做 SSE 流式推送不知道怎么下手。这三个坑我都踩过,这篇把它们串起来讲清楚。
一个「不更新」的 GET 接口
先看最容易翻车的场景。你在app/api/now/route.ts写了个返回当前时间的接口:
// app/api/now/route.ts —— 错误示范exportasyncfunctionGET(){returnResponse.json({now:newDate().toISOString()})}本地next dev一切正常,每次刷新时间都变。但next build && next start后,你会发现时间永远是构建那一刻,再刷新也不变。
原因:App Router 里 Route Handler 的 GET 默认会被静态化(相当于构建时执行一次,结果缓存下来)。这和 Pages Router 完全不同,是很多人踩的第一个坑。
修法有两种,按需求选:
// 方式一:显式声明这个路由不缓存exportconstdynamic='force-dynamic'exportasyncfunctionGET(){returnResponse.json({now:newDate().toISOString()})}// 方式二:用 revalidate 做定时缓存(比如 60 秒更新一次)exportconstrevalidate=60exportasyncfunctionGET(){returnResponse.json({now:newDate().toISOString()})}判断依据很简单:接口结果跟请求无关、可以缓存(如首页配置、商品列表)就用revalidate;每次都要最新(如当前用户、实时数据)就force-dynamic。另外,只要你在 handler 里读了request的headers、cookies或searchParams,Next.js 会自动把它标成动态,不用手动声明——但读new Date()这种「外部副作用」它是察觉不到的,所以才需要你显式标注。
动态参数:第二个参数别写错
带参数的路由,比如app/api/user/[id]/route.ts,新手常这么取参数:
// 错误:GET 的第一个参数是 Request,不是 paramsexportasyncfunctionGET(params){console.log(params.id)// undefined}正确姿势是从第二个参数里解构params。注意 Next.js 15 起params变成了 Promise,要await:
// app/api/user/[id]/route.tsimport{NextRequest}from'next/server'exportasyncfunctionGET(req:NextRequest,{params}:{params:Promise<{id:string}>}){const{id}=awaitparams// Next.js 15 起需要 await// 查询字符串从 req.nextUrl 上取,别去 params 里找constverbose=req.nextUrl.searchParams.get('verbose')constuser=awaitgetUser(id)if(!user){// 返回 404 用状态码,不要返回 200 再塞个 error 字段returnResponse.json({error:'not found'},{status:404})}returnResponse.json(verbose?user:{id:user.id,name:user.name})}asyncfunctiongetUser(id:string){// 这里替换成你的真实查询return{id,name:'Alice',email:'a@x.com'}}两个关键点:路径参数([id])从params拿,查询参数(?verbose=1)从req.nextUrl.searchParams拿,两者来源不同别搞混;返回错误时用真实 HTTP 状态码,别用「200 + error 字段」那套,前端res.ok才能正确判断。
流式返回:SSE 推送进度
最后是进阶场景。假设你有个耗时任务(比如调用大模型、批量处理),想边算边把进度推给前端,而不是让用户干等。这时候用ReadableStream做 Server-Sent Events:
// app/api/progress/route.tsexportconstdynamic='force-dynamic'// 流式接口一定不能被缓存exportasyncfunctionGET(){constencoder=newTextEncoder()conststream=newReadableStream({asyncstart(controller){for(leti=1;i<=5;i++){awaitnewPromise((r)=>setTimeout(r,500))// 模拟耗时步骤// SSE 格式:data: <内容>\n\n,两个换行是消息分隔符,少一个前端收不到constchunk=`data:${JSON.stringify({step:i,total:5})}\n\n`controller.enqueue(encoder.encode(chunk))}controller.close()// 忘了 close,前端连接会一直挂着},})returnnewResponse(stream,{headers:{'Content-Type':'text/event-stream','Cache-Control':'no-cache',Connection:'keep-alive',},})}前端用原生EventSource接:
constes=newEventSource('/api/progress')es.onmessage=(e)=>{const{step,total}=JSON.parse(e.data)console.log(`进度${step}/${total}`)if(step===total)es.close()// 收完手动关,否则会自动重连}这里最容易漏的两处:一是 SSE 每条消息必须以\n\n结尾,只写一个换行前端事件根本不触发;二是服务端controller.close()和客户端es.close()都要记得调,不然连接泄漏,部署到 serverless 平台还会一直计费。
小结
- GET 默认静态化是 App Router 最大的行为差异:结果要实时就
export const dynamic = 'force-dynamic',能缓存就export const revalidate = N。 - 动态路由参数在第二个参数的
params里,Next.js 15 起要await;查询参数在req.nextUrl.searchParams,两者来源不同。 - 流式返回用
ReadableStream+text/event-stream,记住 SSE 消息以\n\n结尾、两端都要主动 close。
一句话记忆:App Router 的 Route Handler 默认是「静态优先」的,凡是要动态就得显式声明,这是它和 Pages Router 最本质的区别。