DEV Community

Cover image for How to optimize your Next.js app with after()
Megan Lee for LogRocket

Posted on • Originally published at blog.logrocket.com

How to optimize your Next.js app with after()

Written by Temitope Oyedele✏️

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.

What is 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.

Where can you use after()?

after() can be used in four different areas:

  • Server Components
  • Server Actions
  • Route handlers
  • Middleware

But it doesn't behave the same way in all of them!

Server Components

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); } } 
Enter fullscreen mode Exit fullscreen mode

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'.

Server Actions

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 }; } 
Enter fullscreen mode Exit fullscreen mode

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.

Route handlers

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 }); } 
Enter fullscreen mode Exit fullscreen mode

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.

Middleware

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; } 
Enter fullscreen mode Exit fullscreen mode

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

How after() functions behave when nested

after()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:

  • The page logs a product view
  • The product layout logs the current A/B test variant
  • The root layout logs a general page view

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>; }``` ```typescript // app/product/layout.tsx import { after } from 'next/server'; export default function ProductLayout({ children }) { after(() => console.log('[AFTER] product layout')); return <section>{children}</section>; } 
Enter fullscreen mode Exit fullscreen mode
// app/product/page.tsx import { after } from 'next/server'; export default function ProductPage() { after(() => console.log('[AFTER] product page')); return <h1>Product</h1>; } 
Enter fullscreen mode Exit fullscreen mode

If a user hits this page, you’ll see this in your logs:

[AFTER] product page [AFTER] product layout [AFTER] root layout 
Enter fullscreen mode Exit fullscreen mode

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.

How after() behaves during errors

A 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:

  • The page crashes?
  • A 500 error is sent?
  • The route is thrown before rendering starts?

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'); } 
Enter fullscreen mode Exit fullscreen mode

When you hit /api/error-test, you’ll get a 500 response, but your server logs will still print:

[AFTER] Logging userId: abc123 
Enter fullscreen mode Exit fullscreen mode

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)); 
Enter fullscreen mode Exit fullscreen mode

This way, even if the rest of the handler fails, your log still has the info you need.

Important things to note about the after() function

Before 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 } }); }); 
Enter fullscreen mode Exit fullscreen mode

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.

Alternatives to 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:

  • Logging
  • Analytics
  • Sending emails
  • Syncing with third-party APIs
  • Any side effect that doesn’t need to block the response

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.

Conclusion

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.


LogRocket: Full visibility into production Next.js apps

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.

LogRocket Trial Banner

Modernize how you debug your Next.js apps — start monitoring for free.

Top comments (0)