Next.js App Router in Production · Streaming, Security and Operations

Self-Hosting: Standalone Output, Health, after() and Headers

Run Next.js in production on your own servers: standalone output, security headers, health endpoints, after() for post-response work, and shared caches.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A production configuration: standalone output for a small container image, security headers for every route, no X-Powered-By header, a health endpoint, and an orders API that sends its confirmation email with after(), after the response has gone out.

The idea

  • output: "standalone" produces .next/standalone with a minimal server.js and only the needed node_modules; copy it with public/ and .next/static into the image and run node server.js.
  • headers() in next.config.ts adds headers such as HSTS, X-Content-Type-Options and a Content Security Policy.
  • after() runs work after the response is sent (logging, emails, analytics) without delaying the user; the server waits for pending after callbacks during graceful shutdown.
  • With several instances, use a shared cache handler (cacheHandlers with "use cache: remote") and set deploymentId so rolling deployments don't mix assets from different builds.

The code

Production config, health check and after()
// file: next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  output: "standalone",
  poweredByHeader: false,
  deploymentId: process.env.DEPLOYMENT_ID,
  async headers() {
    return [{
      source: "/:path*",
      headers: [
        { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
        { key: "X-Content-Type-Options", value: "nosniff" },
        { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
      ],
    }];
  },
};
export default nextConfig;

// file: .env
DEPLOYMENT_ID=2026-10-26-3

// file: app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return <html lang="en"><body><main>{children}</main></body></html>;
}

// file: app/api/health/route.ts
import { connection } from "next/server";

export async function GET() {
  await connection();                                  // run per request, never prerendered
  const databaseUp = true;                             // a real check pings the database with a short timeout
  return Response.json(
    { status: databaseUp ? "ok" : "unavailable", deployment: process.env.DEPLOYMENT_ID },
    { status: databaseUp ? 200 : 503, headers: { "Cache-Control": "no-store" } },
  );
}

// file: app/api/orders/route.ts
import { after } from "next/server";

export async function POST(request: Request) {
  const { item } = await request.json();
  const order = { id: "o42", item };
  after(async () => {
    await new Promise((resolve) => setTimeout(resolve, 100));     // e.g. send the confirmation email
    console.log(`[app] confirmation email sent for ${order.id}`);
  });
  console.log(`[app] order ${order.id} saved, responding`);
  return Response.json(order, { status: 201 });
}

// headers: x-powered-by, strict-transport-security, x-content-type-options, cache-control
// request: GET /api/health
// request: POST /api/orders {"item": "Teapot"}
// wait: 0.3

Output

GET /api/health -> 200 (strict-transport-security: max-age=63072000; includeSubDomains; preload, x-content-type-options: nosniff, cache-control: no-store)
{"status": "ok", "deployment": "2026-10-26-3"}
POST /api/orders -> 201 (strict-transport-security: max-age=63072000; includeSubDomains; preload, x-content-type-options: nosniff)
[app] order o42 saved, responding
{"id": "o42", "item": "Teapot"}
[app] confirmation email sent for o42

Run this on your computer — see “Get Started” for setup.

Notice there's no x-powered-by header. The order response is sent before the email work runs; its log line appears after the "responding" line. In a container you'd run node .next/standalone/server.js rather than next start.

Common mistakes

  • Shipping the full repository and node_modules in the image instead of the standalone output.
  • Several instances with the default in-memory cache: users see different data and tag invalidation reaches one instance only.
  • Doing slow side effects before returning the response instead of in after() or a queue.
  • Killing the process immediately on deploy; send SIGTERM and allow a drain period (10-30 s) so in-flight requests and after() work finish.
  • Putting secrets in NEXT_PUBLIC_ variables or baking them into the image at build time.

Key points

  • Standalone output for small images; security headers in next.config.
  • Health endpoints run per request and don't get cached.
  • after() for post-response work; shared caches and deploymentId for multiple instances.

Further reading: Self-hosting · output: standalone · after · Production checklist

Practice questions

What does output: "standalone" produce?

Answer

A .next/standalone folder with a minimal server.js and only the node_modules files the app needs, for small deployment images (public/ and .next/static are copied alongside).

What is after() for?

Answer

Scheduling work to run after the response is sent, such as logging or sending emails, so it doesn't delay the user.

What changes when you run several Next.js instances?

Answer

Caches must be shared (custom cache handlers, use cache: remote) so all instances see the same data and invalidations, and deploymentId helps handle version skew during rolling deployments.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?