Deploying Next.js Apps
Vercel vs self-hosting with next start or Docker, and environment variables per environment.
Deployment isn't one-size-fits-all with Next.js
A Next.js app can render statically, server-render per request, run Route Handlers and Server Actions, and serve images through its own optimization pipeline — which means "deploying it" involves more choices than uploading a folder of static HTML. The two realistic paths are deploying to Vercel (the company that builds Next.js, and its natural hosting target) or self-hosting it yourself, most commonly with next start behind your own process manager, or inside a Docker container.
Vercel: the zero-config path
Vercel is built by the Next.js team specifically to match the framework's rendering model feature-for-feature — every rendering mode covered elsewhere in this track (static, dynamic, ISR) works out of the box with no extra configuration:
npm install -g vercel
vercel
Running vercel from a project's root walks through linking it to a Vercel project and deploys it; pushing to a connected Git repository afterward triggers an automatic deployment on every push, with a unique preview URL generated for every pull request before it merges. Concretely, Vercel maps Next's rendering modes onto its own infrastructure automatically: statically generated pages are served from its global CDN edge network, dynamic routes and Route Handlers run as serverless (or edge) functions spun up per request, and ISR's background regeneration is handled natively without you managing a cache invalidation mechanism yourself. This close alignment is the main practical argument for Vercel over self-hosting — features like ISR, image optimization, and middleware are guaranteed to behave exactly as documented, since the hosting platform and the framework are developed by the same team.
Self-hosting with next start
Self-hosting is a completely supported, first-class option — Next.js is an open-source framework, not a product tied to any one host. A production build runs with:
npm run build
npm run start
next build compiles an optimized production bundle; next start runs a persistent Node.js server serving it — this is what actually needs a long-running server process, unlike a purely static site, since it also serves dynamic routes, ISR revalidation, and Route Handlers on demand. In practice this means it needs a process manager (pm2, systemd, or your platform's own equivalent) to keep the Node process alive, restart it if it crashes, and handle graceful reloads on redeploy — none of that is optional, whereas a static host serving a next export-style output has no server process to keep alive at all.
If your app doesn't need SSR/ISR/Route Handlers/Server Actions at all — a purely static site with no server-side logic — output: 'export' in next.config.js produces a plain folder of static HTML/CSS/JS deployable to any static host with no Node.js server required:
// next.config.js
/** @type {import('next').NextConfig} */
module.exports = {
output: 'export',
};
That option is a genuine trade-off, not a strictly better default — it disables every feature that needs a request-time server: Route Handlers, Server Actions, ISR, and Next's server-side image optimization all stop working, since there's no server left at request time to run them.
Deploying with Docker
Docker is the standard way to self-host predictably across different infrastructure (a VPS, Kubernetes, any container-based platform), and Next.js ships an official output: 'standalone' mode specifically to keep the resulting image small:
// next.config.js
/** @type {import('next').NextConfig} */
module.exports = {
output: 'standalone',
};
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
output: 'standalone' makes next build trace exactly which files (and which subset of node_modules) the app actually needs at runtime and copies only those into .next/standalone — instead of shipping the entire, much larger node_modules directory into the image. The two-stage builder/runner Dockerfile above builds in one stage and copies only the compiled output into a fresh, minimal final image, which is the standard pattern for keeping a production container image small.
Environment variables per environment
Next.js reads environment variables from .env.local, .env.production, .env.development, and plain .env, with more specific files taking precedence — but the detail that actually matters in practice is the NEXT_PUBLIC_ prefix:
# .env.local
DATABASE_URL=postgres://user:pass@localhost:5432/mydb # server-only — never sent to the browser
NEXT_PUBLIC_API_BASE_URL=https://api.example.com # inlined into client-side JS at build time
Only variables prefixed NEXT_PUBLIC_ are embedded into the client-side JavaScript bundle at build time; everything else stays server-only and is readable only from Server Components, Route Handlers, and Server Actions. This is a hard boundary worth internalizing before it becomes a real incident: a secret (an API key, a database credential) placed in a NEXT_PUBLIC_-prefixed variable ships directly into the bundle any visitor can inspect in their browser's dev tools.
| Vercel | Self-hosted (next start / Docker) |
|
|---|---|---|
| Setup effort | vercel and a Git push — near zero-config |
You own the server, process manager, and TLS/reverse proxy setup |
| ISR / on-demand revalidation | Native, no extra work | Works, but you manage the underlying cache/storage yourself |
| Image optimization | Built in, no extra config | Works, but needs a properly configured sharp install and enough server resources |
| Scaling | Automatic (serverless/edge functions) | Manual — you provision and scale infrastructure yourself |
| Best fit | Teams wanting the framework's full feature set with minimal ops work | Teams needing specific infrastructure control, data residency, or an existing container platform |
Environment variables themselves are set per-environment through whichever mechanism the host provides — Vercel's dashboard (with separate values definable for Production, Preview, and Development), or your container orchestrator's secrets/config mechanism for a self-hosted deployment — rather than committing different .env files with real secrets into version control.
Common mistakes
- Putting a genuine secret (an API key, a database password) behind the
NEXT_PUBLIC_prefix — it gets bundled directly into client-side JavaScript, visible to anyone who opens the browser's dev tools. - Choosing
output: 'export'for an app that actually needs Route Handlers, Server Actions, or ISR — static export silently drops all of them, and the mismatch often isn't obvious until a specific feature stops working in the exported build. - Running
next startwithout any process manager in production — a single unhandled crash takes the whole app down until someone notices and restarts it manually. - Committing real production secrets into a
.env.productionfile tracked in version control instead of configuring them through the hosting platform's own environment/secrets mechanism.