Skip to content

Deploying a Next.js App

Aman Kumar Singh8 min read
Part 49 of 50From the React & Next.js Complete Guide series
Deploying a Next.js App — article by Aman Kumar Singh

In Error Handling and Loading States, I covered how to keep a Next.js app resilient when things go wrong at runtime. This article picks up where that one leaves off: your app is built, it handles failure gracefully, and now someone has to actually get it in front of users. It's part of the React & Next.js Complete Guide, a series that started with React Fundamentals for Professionals and has been building toward exactly this point.

Deployment is where a lot of Next.js teams get surprised. The process itself isn't the hard part. What trips people up is that Next.js is three things at once: a static site generator, a Node.js app, and an edge runtime, depending on which route you're looking at. The deployment target you pick determines which of those capabilities you actually get to use. Pick the wrong one and you either overpay for infrastructure you don't need, or you quietly lose features like ISR and Server Actions that your app depends on.

I want to walk through the real decision here: what your options are, what each one trades away, and the mistakes that show up after the first deploy works fine and the second one doesn't.

Know what you're actually deploying

Before picking a platform, it helps to separate what Next.js produces at build time into distinct pieces, because they don't all deploy the same way.

  • Static assets. Pages built with generateStaticParams or plain static rendering, plus your JS/CSS bundles and images. These can sit behind any CDN.
  • Server-rendered routes. Pages and Route Handlers that need to run per-request, whether that's reading cookies, hitting a database, or calling revalidateTag.
  • Server Actions. Functions that run on the server in response to a form submission or client call, which need a live Node (or edge) runtime to execute, not just a static host.
  • Middleware. Runs on every matching request before a route handles it, and only works on a runtime that supports it (edge or Node, depending on your config).

The mistake I see most often is treating a Next.js app like a single artifact you can drop anywhere. If your app is 100% static, exporting it and hosting it on any static file host works fine, and that's a legitimate choice for a marketing site or docs. But the moment you add a Server Action, an API route that needs a database connection, or revalidateTag, you need a host that runs a Node or edge process instead of one that just serves static files. Confirm which category your app actually falls into before you pick infrastructure. Retrofitting server capability into a static export later means changing your rendering strategy, not just swapping the host.

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // output: "export" forces a fully static build.
  // Anything relying on Server Actions, cookies-based auth, or ISR
  // will fail to build (or silently no-op) under this setting.
  output: "export",
};

export default nextConfig;

Vercel: the path of least resistance

Vercel built Next.js, and it shows in how little configuration a deploy needs there. Push to a Git branch, Vercel builds it, and every route type, static, SSR, Server Actions, ISR, middleware, gets the runtime it needs without you writing infrastructure code.

# vercel.json (only needed for non-default behavior)
{
  "framework": "nextjs",
  "regions": ["iad1"],
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "X-Frame-Options", "value": "DENY" },
        { "key": "X-Content-Type-Options", "value": "nosniff" }
      ]
    }
  ]
}

The tradeoff is straightforward: you're paying for that convenience, and you're coupling your deployment story to Vercel's platform decisions. For a small team shipping a SaaS product, that's often a good trade. Infrastructure work you don't do is infrastructure work you don't have to maintain, debug at 2am, or hire for. I'd default to Vercel for anything early-stage unless there's a specific reason not to, like a hard compliance requirement to run inside your own VPC, or cost at a scale where the managed premium stops making sense.

Where teams get bitten is assuming Vercel behaves identically to self-hosted Next.js in every respect. Function execution limits, regional deployment defaults, and how ISR revalidation is distributed across their edge network are all Vercel-specific implementation details. If you're testing locally with next start and then deploying to Vercel, don't assume parity; read their current runtime docs for the specific limits that apply to your plan before you commit to an architecture that depends on long-running requests or large payloads.

Self-hosting with Node and Docker

Self-hosting means running next start (or a custom server) inside a container you control, on infrastructure you manage: ECS, a Kubernetes cluster, a plain EC2 instance behind a load balancer, whatever your team already runs.

# Dockerfile
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nodejs \
  && adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
ENV PORT=3000

CMD ["node", "server.js"]

The output: "standalone" config is what makes this Dockerfile lean. Without it, you'd be copying the entire node_modules tree into the final image, including build-time dependencies you don't need at runtime.

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone",
};

export default nextConfig;

Self-hosting earns its complexity when you have infrastructure requirements a managed platform doesn't fit: data residency rules that require a specific region or cloud account, an existing Kubernetes setup you don't want to fragment, or cost at a scale where running your own compute is cheaper than a managed platform's markup. It doesn't earn its complexity as a default choice for a new project, because now you own the health checks, the autoscaling policy, the CDN in front of it, and the incident response when a pod gets OOM-killed at 3am.

The pitfall that catches teams here is forgetting that ISR needs somewhere to write revalidated pages. On a single-instance deploy this is a non-issue; the filesystem cache works fine. The moment you run multiple instances behind a load balancer, each instance has its own on-disk cache, and a revalidation triggered on one instance won't be visible on another until its own cache expires or gets triggered independently. If your app depends on ISR staying consistent across replicas, you need a shared cache handler (Next.js supports a custom cache handler interface for exactly this) backed by something like Redis, not the default filesystem cache.

Environment variables: server versus client, and where they actually load

This is a smaller topic than deployment platforms, but it causes more production incidents than either of the previous two sections, so it earns its own section.

Next.js only exposes environment variables to the browser bundle if they're prefixed with NEXT_PUBLIC_. Everything else stays server-only, which is correct and intentional: your database URL and API secrets should never end up in client JavaScript.

# .env.production
DATABASE_URL=postgresql://user:password@host:5432/app
REDIS_URL=redis://host:6379
NEXT_PUBLIC_API_BASE_URL=https://api.example.com
NEXT_PUBLIC_POSTHOG_KEY=phc_xxx

The pitfall: NEXT_PUBLIC_* variables are inlined into the JavaScript bundle at build time, not read at request time. If you build once and deploy the same artifact across staging and production with different NEXT_PUBLIC_* values, you'll ship the build-time values everywhere, silently. Either build per-environment, or route your public config through a runtime endpoint the client fetches after load, if your platform's deploy model reuses a single build across environments.

The second pitfall is secrets that never make it to the running container at all. A .env.local file works locally because Next.js loads it automatically in dev, but most container-based deploys don't ship your .env files into the image (and shouldn't, since that would bake secrets into a layer that ends up in your registry). Your CI/CD pipeline or hosting platform needs to inject those variables at runtime or build time explicitly, through its own secrets manager, not by copying a dotfile into the Docker build context.

Health checks and graceful shutdown

Once you're self-hosting, you own the parts a managed platform used to handle invisibly: telling your orchestrator when the app is actually ready, and shutting down cleanly when it's asked to stop.

// app/api/health/route.ts
import { NextResponse } from "next/server";

export async function GET() {
  // A real health check should verify the dependencies the app
  // actually needs to serve traffic, not just that the process is alive.
  try {
    await checkDatabaseConnection();
    return NextResponse.json({ status: "ok" }, { status: 200 });
  } catch {
    return NextResponse.json({ status: "unavailable" }, { status: 503 });
  }
}

Wire this into your orchestrator's readiness probe. A liveness probe alone won't catch this. A liveness check that only confirms the Node process is running will happily route traffic to an instance that can't reach its database, because from the process's point of view, nothing crashed.

Graceful shutdown matters for the same reason. When your orchestrator sends SIGTERM during a rolling deploy, in-flight requests need time to finish before the process exits, or you'll cut off users mid-request every time you ship.

// server.ts (custom server, or wherever your process entry point is)
process.on("SIGTERM", () => {
  server.close(() => {
    console.log("HTTP server closed, exiting.");
    process.exit(0);
  });

  // Force exit if connections don't close within a reasonable window,
  // so a stuck connection doesn't block deploys indefinitely.
  setTimeout(() => process.exit(1), 10_000);
});

Give your orchestrator enough of a termination grace period to let this run before it force-kills the container, and make sure your load balancer stops sending new traffic to the instance before the signal fires, not after.

Key takeaways

  • Figure out which route types your app actually uses (static, SSR, Server Actions, middleware) before picking a deployment target; a static export can't serve any of the dynamic ones.
  • Vercel removes most of the deployment decision-making for Next.js specifically, which is a reasonable default for early-stage products unless you have a concrete reason to self-host.
  • Self-hosting with `output: "standalone"` keeps Docker images lean, but it makes you responsible for autoscaling, health checks, and ISR cache consistency across replicas.
  • ISR's default filesystem cache doesn't share state across multiple instances; use a shared cache handler if consistency across replicas matters to your app.
  • Only `NEXT_PUBLIC_*` variables reach the browser, and they're baked in at build time, not read at request time, which matters if you reuse one build artifact across environments.
  • Readiness probes and graceful shutdown handling aren't optional once you're self-hosting; a managed platform used to handle both for you invisibly.

Frequently asked questions

Should I use Vercel or self-host my Next.js app?

Default to Vercel unless you have a specific constraint it doesn't satisfy: data residency requirements, an existing infrastructure investment you don't want to fragment, or cost at a scale where managed hosting's markup outweighs the engineering time to self-host. Most early-stage products don't have that constraint yet.

What does output: "standalone" actually do?

It produces a minimal server bundle in `.next/standalone` that includes only the production dependencies your app needs to run, traced from your actual imports, instead of your full `node_modules`. It's what keeps a self-hosted Docker image from bloating to hundreds of megabytes of unused packages.

Does ISR work the same way when self-hosted versus on Vercel?

Not by default. Vercel's platform distributes and coordinates ISR revalidation across its edge network. A self-hosted deployment with the default filesystem cache handler keeps each instance's revalidated pages local to that instance, so replicas can serve different cached versions of the same page until each independently revalidates.

Can I use the same Docker image across staging and production?

Yes, but only if none of your config is baked in at build time via `NEXT_PUBLIC_*` variables. If it is, build a separate image per environment, or move that configuration to something the client fetches at runtime instead of a build-time inlined constant.

Why is my Next.js app slow to shut down during deploys?

Most likely it's not handling `SIGTERM` at all, so your orchestrator is waiting out its full grace period and then force-killing the process, dropping in-flight requests. Add an explicit shutdown handler that stops accepting new connections and waits for existing ones to finish before exiting.

Do I need a CDN in front of a self-hosted Next.js app?

Generally yes, for the static assets at minimum: JS bundles, CSS, images, and any statically rendered pages. Serving those directly from your app servers instead of a CDN wastes compute on requests that don't need a running Node process at all.

Related articles

Core Web Vitals for React Apps — Aman Kumar Singh
Image and Asset Optimization — Aman Kumar Singh
Building a Design System — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.