after()
When building apps with Next.js, optimization stops being just about code-splitting or lazy loading and starts becoming about making sure your server does only what’s necessary during a request, and defers everything else until after the response has been sent to the client. That’s where Next.js 15’s after()
comes in.
after()
is a new API that gives you a native hook in the post-response lifecycle. It lets you run logic after your route has finished rendering, without blocking the client. No more awkward hacks inside Server Actions, wedging side effects into middleware, or worrying about slowing down your TTFB just to log a DB write or send analytics.
In this article, we’ll take a look at how to use after()
to make your Next.js app more efficient, cleaner, and easier to scale. We’ll cover where it works and where it doesn’t, how it interacts with Server Components, Actions, and route handlers, and what happens when things go wrong.
after()
?after()
is a new hook in the Next.js routing lifecycle that lets you schedule code to run after the response has been sent to the client. It doesn’t block the main request-response cycle and is ideal for handling non-critical side effects like analytics, logging, background tasks, or cache invalidation.
You can think of after()
as similar to Go’s defer
in HTTP handlers. It is a tool that runs cleanup or background logic after the main task finishes. But unlike defer
, after()
is asynchronous, non-blocking, and doesn’t guarantee execution order.
In traditional server logic, if you wanted to perform post-processing, like updating a stats counter or firing a webhook, you’d either do it before the response was sent or use a custom setup like a background job or queue. This method has proven to slow things down, especially if you’re planning on moving fast. With after()
, you now have a clean, first-class way to handle these post-response tasks inside your route handlers.
after()
?after()
can be used in four different areas:
But it doesn’t behave the same way in all of them!
In Server Components, after()
‘s job is to run after the server has rendered and streamed the HTML to the client. If you need to perform logging, analytics, or track whether a request was made without delaying the actual rendering, this is where after()
will shine.
Keep in mind that you can’t use request APIs like cookies()
or headers()
inside the after()
block. This is because Next.js needs to know which part of the component tree accesses these request APIs to support partial prerendering, but after()
runs after React’s rendering lifecycle.
Let’s say you want to know how often users actually reach the homepage, not just hit the route. So, you decide to track the impressions of the <Hero />
component, a key part of your landing page, and only log it after it’s fully rendered and streamed:
// app/page.tsx import { after } from 'next/server'; import { logHeroImpression } from '@/lib/analytics'; export default function HomePage() { const showHero = true; // maybe controlled by AB test or flag if (showHero) { after(() => { logHeroImpression({ path: '/', experiment: 'hero-v2' }); }); } return ( <main> {showHero && <Hero />} <OtherContent /> </main> ); } //lib/analytics.ts import fs from 'fs'; import path from 'path'; type HeroImpressionData = { path: string; experiment: string; timestamp?: string; }; export function logHeroImpression({ path: routePath, experiment, timestamp = new Date().toISOString(), }: HeroImpressionData) { const logEntry = `[${timestamp}] Hero impression on route: "${routePath}", experiment: "${experiment}"\n`; const logFilePath = path.join(process.cwd(), 'logs', 'hero-impressions.log'); try { fs.mkdirSync(path.dirname(logFilePath), { recursive: true }); fs.appendFileSync(logFilePath, logEntry); console.log('Hero impression logged'); } catch (err) { console.error('Failed to log hero impression:', err); } }
Here, the after()
block only runs if the <Hero />
component is actually rendered, which means it follows your render logic. If the component doesn’t show up, nothing gets logged because the logging occurs after the HTML is streamed to the client, ensuring that the user experience is not compromised.
With this approach, the need for client-side tracking scripts or useEffect
hacks is eliminated. You’re handling impression tracking on the server, where it’s cleaner and more reliable than in-browser JavaScript, which can break or get blocked.
One thing to watch out for is that if your page is statically rendered, for example, using generateStaticParams
, after()
will run at build time or during revalidation, not per user. If you need real-time logs tied to actual requests, make sure your route is using dynamic rendering by setting export const dynamic = 'force-dynamic'
.
When working with Server Actions, after()
runs after the action finishes executing and the response is sent back to the client. This makes it ideal for background tasks that don’t slow down form submissions or UI updates, and tasks like sending emails, logging metrics, or syncing with a third-party API.
Unlike Server Components, Server Actions have access to the request context at the time of execution. You can use request APIs like cookies()
and headers()
inside the after()
block, but you need to use them with proper async/await syntax.
Say you’re building a registration form. After a user signs up, you want to send a welcome email, but you don’t want that email process to delay the form’s response. With after()
, you can decouple the email logic and handle it after the response is out:
'use server'; import { after } from 'next/server'; import { sendWelcomeEmail } from '@/lib/email'; import { db } from '@/lib/db'; export async function registerUser(formData: FormData) { const email = formData.get('email') as string; const user = await db.user.create({ data: { email }, }); after(async () => { await sendWelcomeEmail(email); }); return { success: true, userId: user.id }; }
Once the response is sent back to the client, the sendWelcomeEmail()
function will run in the background, and the user will see a fast UI response without having to wait for the email logic.
You can think of after()
as registering a unit of work that Next.js will run on the server after the Server Action finishes, but still within the same server process. It separates the background task from the core logic, so it won’t delay the user’s response.
This is where after()
hits its full stride. In route handlers like app/api/xyz/route.ts
, where you’re working directly with the Request
and Response
objects and need to handle things like logging, webhooks, or analytics, you can use after()
to run that logic.
In these use cases, after()
gives you a clean way to offload any non-critical background tasks without blocking the response cycle. This means no more cramming side effects into the main request logic or worrying about response delays.
Let’s take a look at how to use the after()
function to log a checkout event with metadata. Imagine a typical checkout API route. When a user completes a purchase, you want to log the event for analytics, which includes additional details such as their IP address and user agent. But this kind of logging isn’t something the client needs to wait for. By using after()
, you can handle all of that logging after the response has already been sent:
// app/api/checkout/route.ts import { after } from 'next/server'; import { headers } from 'next/headers'; import { logCheckoutEvent } from '@/lib/logging'; export async function POST(request: Request) { const body = await request.json(); const order = await createOrder(body); after(async () => { const userAgent = (await headers()).get('user-agent') || 'unknown'; const ip = (await headers()).get('x-forwarded-for') || 'unknown'; await logCheckoutEvent({ orderId: order.id, userAgent, ip, timestamp: new Date().toISOString(), }); }); return Response.json({ success: true, orderId: order.id }); }
This way, the user gets a fast response, while your server quietly logs the event in the background, avoiding any impact on the user experience. This helps ensure that you’re not sacrificing performance just to collect logs or fire off analytics.
If you tried to handle that logging inline, you’d end up holding the response hostage for something the user doesn’t even see. And that cost only grows if the work involves slow third-party services, large payloads, or multiple async operations like sending emails or pinging webhooks.
Offloading that work to after()
would help you maintain a fast user experience while still providing a full audit trail, cleanly and reliably.
In middleware, after()
runs after the response is sent to the client. It’s designed for lightweight, non-blocking side effects that relate to routing or request inspection, such as logging, tagging, or background monitoring. Unlike with route handlers, you don’t have access to the full response body here, and you can’t modify the response inside the after()
block. It’s purely for side effects, not for shaping what gets returned.
Say you want to log every request that hits your app. Instead of embedding this logic directly in every route, you can use middleware to log once, globally, without affecting the request flow:
// middleware.ts import { NextResponse } from 'next/server'; import { after } from 'next/server'; import { logRequest } from '@/lib/traffic'; export function middleware(request: Request) { const response = NextResponse.next(); const url = request.url; const ip = request.headers.get('x-forwarded-for') || 'unknown'; const userAgent = request.headers.get('user-agent') || 'unknown'; after(async () => { await logRequest({ url, ip, userAgent, timestamp: new Date().toISOString() }); }); return response; }
What this does is that as each request comes in, the middleware inspects it, lets it continue with NextResponse.next()
, and then uses after()
to log metadata, such as the URL, IP address, and user agent, in the background. The logging happens separately and doesn’t affect the request or the page response in any way.
Because middleware runs on every request, it’s an ideal place to use after()
for capturing global side effects without duplicating logic across routes.
Below is a table showing how their usages differ:
Context | When it runs | Access to request info | Use cases | Notes |
---|---|---|---|---|
Server Components | After server renders and streams HTML to client | No (cannot use cookies/headers in after() due to PPR requirements) | Render-based logging, analytics, impressions | Runs only if component is rendered; be cautious with static rendering |
Server Actions | After the Server Action completes and the response is sent | Yes (can use headers/cookies with async/await) | Background tasks like sending emails, syncing APIs | after() runs in same process, non-blocking for UI |
Route Handlers | After request handling is done and response is sent | Yes (can read headers and metadata with async/await) | Event logging, analytics, third-party calls | Ideal for offloading heavy background work without blocking user |
Middleware | After the response is sent to the client | Yes (limited, headers only; can’t modify response in after) | Global request logging, monitoring, tagging | Only for side effects; can’t shape or modify the actual response |
after()
functions behave when nestedafter()
can be nested inside other after()
calls. This has real implications when you’re working with layouts, pages, or components stacked on top of each other.
If you’re wondering what happens when multiple components each register their own after()
call, the answer is that they all run, and they run in the reverse order of their rendering. In other words, the most deeply nested component runs first, then its parent, then the next one up. It’s a last-in, first-out model.
This is the opposite of how middleware works, where logic flows top-down. With after()
, it’s more like a cleanup stack where each layer pushes its task, and those tasks get executed after the response, from the inside out.
Say you have got a product page wrapped in a product section layout, then wrapped again in a root layout:
You want all of these logs to run, but you want the detailed logs to come first. That’s exactly what after()
gives you:
// app/layout.tsx import { after } from 'next/server'; export default function RootLayout({ children }) { after(() => console.log('[AFTER] root layout')); return <html><body>{children}</body></html>; }
// app/product/layout.tsx import { after } from 'next/server'; export default function ProductLayout({ children }) { after(() => console.log('[AFTER] product layout')); return <section>{children}</section>; }
// app/product/page.tsx import { after } from 'next/server'; export default function ProductPage() { after(() => console.log('[AFTER] product page')); return <h1>Product</h1>; }
If a user hits this page, you’ll see this in your logs:
[AFTER] product page [AFTER] product layout [AFTER] root layout
So, yes, after()
calls a cascade, but not top-down, like middleware. It runs bottom-up, starting from the deepest rendered component.
Please note that it is not the same as multiple after()
s in one file. Don’t confuse this with calling after()
multiple times inside the same component. Those are also stacked and run in reverse, but they’re scoped locally, meaning they don’t care about layout boundaries.
after()
behaves during errorsA big question developers will have before trusting after()
for anything critical, like logging, analytics, or background tasks, is: what happens when things go wrong? Will it still run if:
500
error is sent?According to its documentation, after()
will be executed even if the response didn’t complete successfully, including when an error is thrown.
This is a major trust point. If you’re using after()
for observability, such as logging failed checkouts, tracking what crashed, or saving request metadata, you need it to always run, not just on clean responses.
Here’s a simple route that throws an error during execution but still defines an after()
callback:
// app/api/error-test/route.ts import { NextResponse } from 'next/server'; import { after } from 'next/server'; export async function GET() { const userId = 'abc123'; after(() => { console.log('[AFTER] Logging userId:', userId); }); throw new Error('Simulated failure'); }
When you hit /api/error-test
, you’ll get a 500 response, but your server logs will still print:
[AFTER] Logging userId: abc123
So, how is this important?
In real-world apps, things break: routes crash, APIs fail, and components blow up. When that happens, you want to know what led up to it, who the user was, what they were doing, or which input triggered the failure.
With after()
, you can log all that even when your route throws. Without it, you’d have to wrap every route in a try...catch
, handle logging manually, and remember to rethrow the error, which is brittle and easy to mess up. You can centralize this kind of failure logging in one place. It gives you a reliable safety net that runs whether the request finishes cleanly or explodes halfway through.
One thing to keep in mind, though, is that just because after()
runs during a failure doesn’t mean it magically has access to everything. If your route throws before you read the request headers or cookies, that data is gone, and after()
won’t be able to access it.
So, if you need request-specific info like IPs or user agents in your after()
logic, extract it early before anything crashes:
const ip = request.headers.get('x-forwarded-for'); after(() => logErrorWithIP(ip));
This way, even if the rest of the handler fails, your log still has the info you need.
after()
functionBefore using after
, there are a few important things to keep in mind:
1. after()
is not a dynamic API
This is because it’s not a hook or reactive runtime. after()
doesn’t re-run when props or state change, and it doesn’t track dependencies like React Hooks do. It runs once during the server-side render, after the response is sent. That’s it.
If you need to conditionally run logic, you have to manually gate it, like wrapping the call in an if
statement based on render conditions.
2. You can use React.cache()
to deduplicate work
If you’re calling expensive functions (like DB reads or API calls) inside after()
, wrap those calls in React.cache()
. This gives you per-request memoization, so multiple after()
calls won’t duplicate work:
import { cache } from 'react'; const getUser = cache(async (id) => { return db.user.findUnique({ where: { id } }); });
This is especially useful if after()
is declared in multiple nested components, and each tries to fetch the same data.
3. It’s not reactive, it’s static
after()
is a static export, meaning it runs once per request during the server render cycle. It doesn’t respond to state changes, props updates, or anything dynamic. If you want conditional logic in after()
, you need to wrap it in an if
or place it inside a conditional render branch.
You can’t use useState
, useEffect
, or expect it to respond to user interactions — this is server-side code that runs after the response is sent, and it’s tied to the render lifecycle, not the browser.
4. It only works on app routes
This function is only supported in the app
directory, not the old pages
directory. That means if you’re still using pages/api
, getServerSideProps
, or other legacy Next.js APIs, you’re out of luck.
Also, after()
only runs on the server. There’s no client equivalent. It won’t run inside client components, and trying to use it there will throw an error.
after()
There are a few alternative tools built for post-response logic. Which one to use depends entirely on where you’re writing your code. The main alternatives are waitUntil()
and plain old await
.
If you need to run something after the response is sent, without slowing the user down, then after()
is your best bet. It’s ideal for:
But if you’re working inside Edge Middleware, then waitUntil()
is what you want. It lets you run background tasks after the middleware returns a response. This is very similar to after()
, but only works in the Edge runtime.
On the other side, if the task must finish before sending a response, like saving to a database, verifying input, or processing a payment, then just use await
. This will delay the response until the task completes, but that’s necessary in those cases.
Next.js’ after()
function represents a significant step forward in building performant, scalable web applications. It provides you with a native way to defer non-critical operations until after the response is sent, and this eliminates one of the most common performance bottlenecks in server-side applications: blocking the user while handling side effects.
The key to using after()
effectively is understanding its limitations and choosing the right context for your use case. Remember that it’s not reactive, requires proper async/await patterns for request APIs, and has different capabilities depending on where you use it. But when used correctly, after()
can dramatically improve your application’s perceived performance and user experience.
Debugging Next applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.
LogRocket captures console logs, errors, network requests, and pixel-perfect DOM recordings from user sessions and lets you replay them as users saw it, eliminating guesswork around why bugs happen — compatible with all frameworks.
LogRocket's Galileo AI watches sessions for you, instantly identifying and explaining user struggles with automated monitoring of your entire product experience.
The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.
Modernize how you debug your Next.js apps — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowApple Intelligence is here. What does it mean for frontend dev and UX? Explore the core features of the update, do’s and don’ts for designing with Apple Intelligence in mind, and reflect on the future of AI design.
JavaScript loops like for
, for...of
, and for...in
are constructs that help run a piece of code multiple times.
You don’t need to guess what’s wrong with your Next.js app. I’ve mapped out the 8 biggest performance traps and the fixes that actually work.
Learn how to truncate text with three dots in CSS, and use two reliable CSS text truncation techniques while covering single-line and multi-line truncations.