after() schedules a callback to run once Next.js has finished sending the response. Import it from next/server and call it inside a Server Component, Server Function, Route Handler or Proxy, and the work happens after the user already has their HTML or JSON. It has been stable since Next.js 15.1 and it does not make a route dynamic.
Here it is in a Route Handler, recording an order event without holding up the 201:
// app/api/orders/route.ts
import { after } from 'next/server'
import { headers } from 'next/headers'
import { createOrder, recordOrderEvent } from '@/app/lib/orders'
export async function POST(request: Request) {
const order = await createOrder(await request.json())
after(async () => {
const userAgent = (await headers()).get('user-agent') ?? 'unknown'
await recordOrderEvent({ orderId: order.id, userAgent })
})
return Response.json({ id: order.id }, { status: 201 })
}
The response goes out first. The analytics write happens afterwards, on the same invocation.
Where can you call after(), and what happens on a static page?
The API reference lists four places: Server Components (including generateMetadata), Server Functions, Route Handlers, and Proxy. Proxy is the file convention that the Vercel docs and the older Next.js release notes still call Middleware, so if you are following a 2024-era tutorial, that is the same thing.
Static export does not support it at all. A Node.js server via next start does, so does a Docker container, and anything behind a deployment adapter is platform-specific.
The part that catches people out: after is not a request-time API, so calling it does not opt the route into dynamic rendering. That sounds like a feature until you put an analytics call in a statically rendered page. The docs are blunt about what happens next. The callback executes at build time, or whenever the page is revalidated. You get one event per build, not one per visitor, and nothing warns you.
If you want per-request behaviour you need the route to be dynamic for some other reason: it reads cookies() or headers(), or it awaits connection(). after on its own will not get you there.
Can you read cookies and headers inside the callback?
Sometimes, and the rule is not obvious.
In Route Handlers and Server Functions, calling cookies() and headers() directly inside the after callback works. That is the example above, and it is the case the 15.1 release notes specifically called out as fixed.
In Server Components, including pages, layouts and generateMetadata, the same code throws a runtime error. The reason is Partial Prerendering and Cache Components: Next.js has to know which part of the component tree touches request data so it can decide what belongs in the static shell, and after runs once React’s render lifecycle is over. By then the answer is unavailable.
Read the values during render and close over them instead:
// app/page.tsx
import { after } from 'next/server'
import { cookies, headers } from 'next/headers'
import { logUserAction } from '@/app/utils'
export default async function Page() {
// Read during the component's render, which is allowed
const userAgent = (await headers()).get('user-agent') ?? 'unknown'
const sessionId = (await cookies()).get('session-id')?.value ?? 'anonymous'
after(() => {
logUserAction({ sessionId, userAgent })
})
return <h1>My Page</h1>
}
With Cache Components enabled, push the request-data read into a child under <Suspense> and call after from there. The static shell still prerenders, and the dynamic component supplies the closure.
How long does the callback get to run?
For the platform’s default or configured maximum duration of the route, which you set per segment:
// app/api/orders/route.ts
export const maxDuration = 30
maxDuration has been available since v13.4.10 and its default is whatever your deployment platform decides. Two consequences worth internalising.
First, the callback is inside the request’s time budget, not outside it. On Vercel, after is backed by waitUntil, and the @vercel/functions reference states that promises passed to waitUntil share the function’s timeout and are cancelled if the function times out. A slow callback is billed and bounded like the request that spawned it.
Second, after runs even when the response failed. Errors, notFound() and redirect() all still trigger it. That makes it a reasonable place for audit logging, where you want a record of the attempt regardless of the outcome. You can also nest after calls inside each other, and use React’s cache to deduplicate helpers called from within one.
What does self-hosting change?
Less than people expect, but the one thing it changes matters.
after is fully supported with next start. The catch is shutdown. Next.js finishes in-flight requests and pending after callbacks when it receives SIGINT or SIGTERM, but only if something gives it time. The self-hosting guide recommends a drain period of 10 to 30 seconds. On Kubernetes that is terminationGracePeriodSeconds on the pod spec; a default rollout that kills containers in a few seconds will quietly drop background work every deploy.
If you are running on a platform that is not Vercel and not a plain Node server, after needs a waitUntil primitive injected into a global. Next.js looks for it like this:
const RequestContext = globalThis[Symbol.for('@next/request-context')]
const contextValue = RequestContext?.get()
const waitUntil = contextValue?.waitUntil
Adapters populate that with AsyncLocalStorage. If nobody has, your callbacks may never run in production while working perfectly in next dev, which is a miserable class of bug to chase. Worth checking before you commit to a host. We cover the wider trade-offs in our Cloudflare Pages and Workers comparison, and it is the sort of thing we check early on cloud hosting and deployment work.
When is after() the wrong tool for background work?
When the work has to happen.
after() gives you no retries, no persistence, no dead-letter queue and no visibility beyond whatever you log yourself. If the process is recycled mid-callback, the work is gone and nothing tells you. There is no backpressure either: one request produces one callback, so a traffic spike becomes a proportional spike of writes against whatever downstream service you are calling.
It is a good fit for telemetry, cache warming, audit rows, and sending an event onto a broker. It is a bad fit for sending customer email, fanning out webhooks with retries, generating exports, or anything that touches billing.
The pattern that survives contact with production is to use after() to hand work to a durable system rather than to be one. Enqueue inside the callback, keep it under a second, and let a worker own the retries:
after(async () => {
await queue.send({ type: 'order.created', orderId: order.id })
})
That way a lost callback costs you one missing enqueue instead of a half-finished job. If you are instrumenting rather than queueing, wire the callback into the tracing you already have rather than a bespoke logger, which our OpenTelemetry setup guide covers.
Ask one question of every callback you are about to write: what happens if this silently does not run? If the honest answer is worse than a gap in a dashboard, it does not belong in after().