Cache at the protocol level — set Cache-Control on your API responses, return 304 Not Modified with ETags, and use s-maxage and stale-while-revalidate so a CDN caches for you.
The cheapest cache is the one you never build: tell the client (and any CDN) it may reuse a response for a while with a Cache-Control header. max-age is the seconds it stays fresh; public lets shared caches store it, private restricts it to the user's browser. Set it on the Response from a Route Handler. Use this for data that tolerates being a little stale.
// app/api/products/route.ts
export async function GET() {
const products = await db.product.findMany()
return Response.json(
{ data: products },
{
headers: {
// Reusable for 60s; a shared cache (CDN) may store it.
'Cache-Control': 'public, max-age=60',
},
}
)
}An ETag is a fingerprint of the response body. You send it on the first response; the client sends it back in If-None-Match next time. If it still matches, you skip re-sending the body and return an empty 304 Not Modified — the client reuses what it already has. It saves bandwidth and is ideal for larger payloads that change rarely.
import { createHash } from 'crypto'
import type { NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
const products = await db.product.findMany()
const body = JSON.stringify({ data: products })
// Fingerprint the body. If the client already has it, stop here.
const etag = '"' + createHash('sha1').update(body).digest('hex') + '"'
if (request.headers.get('if-none-match') === etag) {
return new Response(null, { status: 304 }) // no body re-sent
}
return new Response(body, {
headers: { 'Content-Type': 'application/json', ETag: etag },
})
}s-maxage is a max-age that only shared caches (CDNs) obey, so you can cache hard at the edge while keeping the browser cautious. Pair it with stale-while-revalidate: after it goes stale, the CDN serves the old copy instantly to the current user and refreshes in the background — so visitors almost never wait on a cold cache.
export async function GET() {
const data = await getExpensiveReport()
return Response.json(data, {
headers: {
// Browser: don't cache. CDN: cache 1h, then serve stale up to a
// day while it revalidates in the background.
'Cache-Control':
'public, max-age=0, s-maxage=3600, stale-while-revalidate=86400',
},
})
}