Next.js Route Handler 写 API:缓存、动态参数与流式返回全踩一遍

Next.js Route Handler 写 API:缓存、动态参数与流式返回全踩一遍
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 完全不同,是很多人踩的第一个坑。修法有两种,按需求选:// 方式一:显式声明这个路由不缓存exportconstdynamicforce-dynamicexportasyncfunctionGET(){returnResponse.json({now:newDate().toISOString()})}// 方式二:用 revalidate 做定时缓存(比如 60 秒更新一次)exportconstrevalidate60exportasyncfunctionGET(){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}fromnext/serverexportasyncfunctionGET(req:NextRequest,{params}:{params:Promise{id:string}}){const{id}awaitparams// Next.js 15 起需要 await// 查询字符串从 req.nextUrl 上取,别去 params 里找constverbosereq.nextUrl.searchParams.get(verbose)constuserawaitgetUser(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:ax.com}}两个关键点:路径参数([id])从params拿,查询参数(?verbose1)从req.nextUrl.searchParams拿,两者来源不同别搞混;返回错误时用真实 HTTP 状态码,别用「200 error 字段」那套,前端res.ok才能正确判断。流式返回:SSE 推送进度最后是进阶场景。假设你有个耗时任务(比如调用大模型、批量处理),想边算边把进度推给前端,而不是让用户干等。这时候用ReadableStream做 Server-Sent Events:// app/api/progress/route.tsexportconstdynamicforce-dynamic// 流式接口一定不能被缓存exportasyncfunctionGET(){constencodernewTextEncoder()conststreamnewReadableStream({asyncstart(controller){for(leti1;i5;i){awaitnewPromise((r)setTimeout(r,500))// 模拟耗时步骤// SSE 格式:data: 内容\n\n,两个换行是消息分隔符,少一个前端收不到constchunkdata:${JSON.stringify({step:i,total:5})}\n\ncontroller.enqueue(encoder.encode(chunk))}controller.close()// 忘了 close,前端连接会一直挂着},})returnnewResponse(stream,{headers:{Content-Type:text/event-stream,Cache-Control:no-cache,Connection:keep-alive,},})}前端用原生EventSource接:constesnewEventSource(/api/progress)es.onmessage(e){const{step,total}JSON.parse(e.data)console.log(进度${step}/${total})if(steptotal)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,两者来源不同。流式返回用ReadableStreamtext/event-stream,记住 SSE 消息以\n\n结尾、两端都要主动 close。一句话记忆:App Router 的 Route Handler 默认是「静态优先」的,凡是要动态就得显式声明,这是它和 Pages Router 最本质的区别。