Deployment and Self-Hosting
The idea in one sentence
Section titled “The idea in one sentence”You can deploy to Vercel with zero config or self-host a Node server behind a CDN — but once you run more than one instance, caching and ISR need a shared cache handler so every instance agrees.
Vercel versus self-hosting
Section titled “Vercel versus self-hosting”Vercel detects a Next.js app and wires up builds, the CDN, image optimization, and Edge/Node runtimes with no configuration. Self-hosting gives you full control: you build the app and run next start (a long-lived Node server) yourself, usually behind a CDN that caches static assets.
# Self-hosting: build once, then run a long-lived Node servernext buildnext start -p 3000A minimal Docker image with output standalone
Section titled “A minimal Docker image with output standalone”Set output: 'standalone' and Next.js traces exactly which files the server needs and copies them into .next/standalone. You get a tiny image that ships without node_modules.
import type { NextConfig } from 'next'
const nextConfig: NextConfig = { output: 'standalone',}
export default nextConfig# Minimal runtime image — copy only the traced standalone outputFROM node:20-alpineWORKDIR /appCOPY --from=builder /app/.next/standalone ./COPY --from=builder /app/.next/static ./.next/staticCOPY --from=builder /app/public ./public# standalone ships its own server.js — do NOT use "next start" hereCMD ["node", "server.js"]Caching and ISR across instances
Section titled “Caching and ISR across instances”ISR, the full-route cache, and revalidateTag all write to a cache. On a single instance the default filesystem cache is fine. Run multiple instances and each has its own local cache — so revalidateTag on one pod leaves the others stale. The fix is a shared cache handler (backed by Redis or similar) that every instance reads and writes.
// next.config.ts — shared cache for multi-instance self-hostingimport type { NextConfig } from 'next'
const nextConfig: NextConfig = { cacheHandler: require.resolve('./cache-handler.js'), cacheMaxMemorySize: 0, // disable the in-memory cache; use the shared handler}
export default nextConfigWith a shared handler, revalidateTag('products') in a Server Action invalidates the entry for every instance at once. Also decide per route between the Node runtime (full Node APIs) and the Edge runtime (fast, limited APIs) based on what each route actually needs.
Go-live checklist
Section titled “Go-live checklist”This is the last stop, so pull the whole course together before you ship:
- Keep data fetching in Server Components; only mark a leaf
'use client'when it truly needs interactivity. - Choose the rendering mode per route deliberately — static, dynamic, or ISR — and confirm each behaves as expected in a production build.
- Understand your caching layers (request memoization, Data Cache, full-route cache) and wire a shared cache handler before scaling past one instance.
- Mutate through Server Actions and call
revalidateTag/revalidatePathso caches stay correct after writes. - Gate protected routes with an optimistic middleware redirect AND a Data Access Layer check.
- Keep secrets unprefixed in
.env.local; expose onlyNEXT_PUBLIC_values.
graph TD A["CDN"] --> B["Instance 1 (next start)"] A --> C["Instance 2 (next start)"] B --> D["Shared cache handler (Redis)"] C --> D D --> E["revalidateTag invalidates all instances"]