DEPLOYMENT

How to host a Next.js app: static export, Node server or Docker

By , FounderUpdated 8 min read

Which option fits your app?

The deciding question is simple: does any part of your app need to run code on a server when a request arrives? Open your project and look for these:

  • Pages or layouts that call cookies() or headers(), or read searchParams on the server
  • route.ts handlers that accept POST requests or read the incoming request
  • A middleware.ts file (used for auth redirects, locale detection, A/B tests)
  • export const revalidate, or fetch calls with a revalidate option (incremental static regeneration)
  • Server Actions (functions marked "use server"), often behind forms
  • next/image with the default image optimiser

If none of these appear, a static export is the cheapest and simplest way to host the app. If any of them do, you need something running Node.js: either next start on a server, or the same server packaged in a container.

Static exportNode.js serverDocker (standalone)
What you deployThe out/ folder: HTML, CSS, JSBuilt project plus node_modules, run with next startAn image that runs node server.js
Per-request server renderingNoYesYes
Route handlers, Server ActionsOnly GET handlers, rendered once at build timeYesYes
Middleware, ISRNoYesYes
Image optimisationOnly with a custom loader, or turned offYesYes
Runs onAny static host or CDNA server with Node.js, behind a reverse proxyAnything that runs containers
Ongoing upkeepAlmost noneProcess manager, Node.js updates, proxy configImage builds, a registry, the container runtime

Option 1: Static export

A static export renders every page to HTML at build time. Nothing runs on the server afterwards, so any web server or CDN can host the result. Turn it on in next.config.ts:

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

const nextConfig: NextConfig = {
  output: "export",
  // Write /about/index.html instead of /about.html (easier on plain web servers)
  trailingSlash: true,
  // Only needed if you use next/image without a custom loader
  images: { unoptimized: true },
};

export default nextConfig;

Then run npm run build (which calls next build). The site lands in an out/ folder. The old next export command is gone; the output setting replaced it. Upload out/ to your host, or point a Git-connected host at the repo with npm run build as the build command and out as the output directory.

Dynamic routes such as app/blog/[slug]/page.tsx still work, but only for the paths you return from generateStaticParams(). Anything not listed there is not generated and will 404.

If you serve out/ from your own Nginx, a minimal server block looks like this:

/etc/nginx/sites-available/mysite
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    root /var/www/mysite/out;

    location / {
        try_files $uri $uri.html $uri/ =404;
    }

    error_page 404 /404.html;
}

Static files are what CDNs are built for, so put one in front. Our guide on putting Cloudflare in front of your server covers SSL modes and cache rules.

What stops working with a static export

Everything that needs a live server. Next.js stops the build with an error for most of these, which is useful: you find out before you deploy, not after.

  • Per-request server rendering. Server Components still run, but once, at build time. Anything that depends on the request (cookies, headers, the logged-in user) cannot be rendered on the server.
  • API routes and route handlers that need a server. A GET handler that returns the same data every time is written out as a static file. POST handlers, or handlers that read the request, are not supported. Server Actions are not supported either.
  • Middleware. There is no server to run it on. Redirects and rewrites in next.config also need a server; set them up in your host or CDN instead.
  • ISR and revalidation. Pages only change when you rebuild and redeploy.
  • Default image optimisation. The built-in optimiser resizes images on request, so it needs a server. Set images.unoptimized to true and size images yourself, or configure a custom loader that points at an image service.

Client-side code is unaffected. A static site can still call an API hosted elsewhere from a client component, so "static frontend plus separate API" is a common middle path.

Option 2: Run it as a Node.js server

This is the default way Next.js runs and supports every feature. On the server you build once and then start the production server:

terminal
npm ci
npm run build
npx next start -H 127.0.0.1 -p 3000

Install all dependencies, not just production ones, before building: next build usually needs devDependencies such as TypeScript and Tailwind. Binding to 127.0.0.1 keeps the app off the public internet so only your reverse proxy can reach it.

Around that command you need three more things:

  • A process manager (systemd or PM2) so the app restarts after a crash or reboot. Running next start in an SSH session stops when you log out.
  • A reverse proxy, usually Nginx, to handle HTTPS, compression and the public ports, and forward requests to port 3000.
  • Environment variables loaded from a file the process manager reads, not committed to the repo.

We walk through all of that, with a systemd unit and an Nginx config, in how to deploy a Node.js app on a Linux server. It applies to Next.js unchanged; your ExecStart just runs next start.

If you run more than one copy of the app (for redundancy or a load balancer), each copy keeps its own ISR and image cache on disk by default. That is fine for one server; for several, look at the cacheHandler option in the Next.js self-hosting docs.

Option 3: A Docker image with standalone output

Setting output: "standalone" makes next build produce .next/standalone: a minimal server.js plus only the node_modules files the server actually uses. That keeps images small. It does not copy public/ or .next/static/, so the Dockerfile has to.

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

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

export default nextConfig;
Dockerfile
FROM node:22-alpine AS base

# 1. Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# 2. Build the app
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

# 3. Small runtime image
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

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
ENV HOSTNAME=0.0.0.0

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

A few lines matter more than they look:

  • HOSTNAME=0.0.0.0: server.js binds to the address in HOSTNAME, and Docker sets that variable to the container ID by default. Without this line the server is not reachable from outside the container.
  • PORT sets the port server.js listens on.
  • If your project has no public/ folder, the COPY fails. Create an empty one or delete that line.
  • The app runs as a non-root user.

Add a .dockerignore containing at least node_modules, .next, .git and your .env* files, so local builds and secrets don't end up in the image. Then build and run:

terminal
docker build -t myapp:1.0.0 .
docker run -d --name myapp --restart unless-stopped \
  --env-file .env.production \
  -p 127.0.0.1:3000:3000 \
  myapp:1.0.0

Publishing on 127.0.0.1 means only a reverse proxy on the same host can reach it. For restart policies, logs, resource limits and safe rollbacks, see running Docker containers in production.

Environment variables: build time vs run time

This catches people on every hosting option.

  • Variables starting with NEXT_PUBLIC_ are copied into the browser JavaScript during next build. Changing them later does nothing until you rebuild. In Docker, that means they are fixed inside the image, so one image cannot serve two environments with different public values.
  • Other variables stay on the server and are read when server code runs. For dynamically rendered pages and route handlers that is at request time, so you can change them and restart. For pages rendered at build time, the build-time value is what ships.
  • With a static export, every value is fixed at build time, because nothing runs afterwards.
  • Never put a secret in a NEXT_PUBLIC_ variable. Anyone can read it in the browser.

Don't commit .env.production with real secrets. Keep it on the server (readable only by the app's user) or in your host's environment settings.

Where NevaraCloud fits

A static export fits our Starter plan: LKR 1,500 a month for one static site (a Next.js export included), deployed from GitHub with free SSL and Cloudflare CDN in front. If your app needs server rendering, API routes or middleware, it needs Node.js hosting, which starts on the Business plan and moves to Pro for heavier full-stack apps. Compare them on the plans page or read more about our Node.js hosting.

If you have already built a Docker image, we can run it as a container; that is quoted in LKR once we see what it needs. We don't sell self-managed VPS servers, so if you want root access and to run everything yourself, the Node.js and Docker steps above are what you will be doing on that server.

Rather we handled it?

Managed hosting billed in LKR, Docker hosting, or hands-on deployment help. Tell us what you're working on.

Ask us on WhatsApp