React & Next.js · 7 min read

Deploying Next.js: Vercel, Self-Hosted and Edge Trade-offs

What ISR, image optimisation and middleware actually require, and what breaks first when you move a Next.js app off Vercel onto your own infrastructure.

By Praful Patel · Last updated
Deploying Next.js: Vercel, Self-Hosted and Edge Trade-offs - article cover
Deploying Next.js: Vercel, Self-Hosted and Edge Trade-offs - article cover

Short answer

Next.js runs anywhere Node runs, but several of its headline features are infrastructure, not code. ISR needs shared, persistent storage. Image optimisation needs a resizing service and a cache. Middleware needs something that runs before the render. On Vercel these exist by default; self-hosting means providing them yourself, and that is where most migrations stall.

The choice is rarely about raw performance. It is about which operational responsibilities you want to own, and what a single-region container does to users on the other side of the world.

What actually differs

Capability Vercel Self-hosted (Node/Docker)
ISR / on-demand revalidation Built in, shared across instances Needs a shared cache handler (Redis or S3)
Image optimisation Managed service, cached at edge Runs in your container, or use a third-party loader
Middleware Edge runtime, near the user Node runtime, in your region
Streaming / RSC Supported Supported; needs a proxy that does not buffer
Preview deployments Automatic per branch Build it in CI yourself
Geographic distribution Default One region unless you build multi-region
Cost shape Usage-based; scales with traffic Fixed capacity; cheaper at steady high volume

The self-hosting details that bite

ISR needs shared storage

By default the incremental cache is written to the local filesystem. With more than one container that means each instance has its own copy, so a user’s response depends on which one they hit, and revalidatePath only invalidates the instance that received the call.

The fix is a custom cache handler backed by something shared:

// next.config.js
module.exports = {
  cacheHandler: require.resolve('./cache-handler.js'),  // Redis-backed
  cacheMaxMemorySize: 0,                                 // disable in-memory LRU
  output: 'standalone',                                  // minimal deployable output
};

Without this, ISR appears to work in staging with one container and behaves erratically in production with four.

Image optimisation is real CPU

next/image resizes and re-encodes on demand. In a container that is your CPU, on the request path, and the results are cached to a filesystem that may not persist. Options: put a CDN in front that caches the optimised URLs, point loader at an external image service, or pre-generate sizes at build time. Leaving it unconfigured is how a single popular page saturates a container.

Standalone output

output: 'standalone' produces a minimal server bundle with only the dependencies actually used — a much smaller image than copying node_modules. Copy .next/static and public alongside it; they are not included, and forgetting them produces a site with no CSS and no images, which is a memorable first deploy.

Do not buffer the response

Streaming and RSC depend on the response being sent progressively. A reverse proxy that buffers turns streaming back into a blocking render with extra steps. In nginx that means proxy_buffering off for the app upstream.

The edge runtime is a different runtime

Edge functions start fast and run close to the user, and they are not Node. No filesystem, no native modules, no most-of-node:*, and a much smaller bundle limit. Many database drivers do not work; you need an HTTP-based client.

Middleware is where this matters most, because middleware runs on every matched request. Two consequences: keep the matcher narrow so it does not run on static assets, and keep the work small. Authentication checks, redirects, header rewrites and A/B bucketing are appropriate. A database lookup on every request is not.

export const config = {
  // Exclude static assets and image optimisation, or middleware runs on all of them.
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

Choosing

  • Vercel — small team, global audience, traffic that varies, and no appetite to operate caching infrastructure. The platform features are the product.
  • Self-hosted containers — existing Kubernetes or ECS practice, data residency requirements, steady high traffic where fixed capacity is cheaper, or a need to sit inside a private network.
  • Static export — genuinely static content, no ISR, no server actions, no image optimisation. Cheap and nearly unbreakable, but the constraints are real.
  • Other managed platforms — reasonable middle ground, but verify ISR and image optimisation specifically rather than trusting a “supports Next.js” claim.

Whichever you choose, the caching semantics described in Next.js Caching Explained are what you are actually deploying, and the budget discipline in Full-Stack Web Performance decides whether the origin keeps up.

A container that behaves in production

Most self-hosting problems are visible in the Dockerfile. A multi-stage build against the standalone output keeps the runtime image small and avoids shipping build tooling to production:

# Build stage
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build            # produces .next/standalone

# Runtime stage
FROM node:22-alpine AS run
WORKDIR /app
ENV NODE_ENV=production
# standalone does NOT include these two - copying them is the step people miss.
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

Two operational details that matter as much as the image. Give the container a real health check endpoint so the orchestrator can tell a slow start from a failed one — Next.js takes a few seconds to be ready, and an aggressive liveness probe will restart it forever. And set memory limits deliberately: Node will happily grow toward the container ceiling and get OOM-killed mid-request rather than garbage collecting.

Environment variables at build versus run

Anything prefixed NEXT_PUBLIC_ is inlined into the client bundle at build time. Changing it later requires a rebuild, not a restart — which is why the same image cannot be promoted unchanged from staging to production if it carries a public API URL. Either build per environment, or keep environment-varying values server-side and expose them through a runtime config endpoint.

The corollary is a security one: a secret accidentally given a NEXT_PUBLIC_ prefix is baked into the JavaScript every visitor downloads, and rebuilding without it does not un-publish the version already served.

Questions people actually ask

Can I self-host Next.js with all features?
Nearly all, but ISR and image optimisation need infrastructure you provide: a shared cache handler backed by Redis or S3, and either a CDN in front of the image endpoint or an external image service. Plan for those explicitly rather than discovering them when the second container starts serving different content.

Why does ISR behave inconsistently on my servers?
Almost certainly a per-instance filesystem cache with more than one instance running. Each container holds its own copy and revalidation only reaches the one that received the request. Configure a shared cacheHandler so all instances read and write the same store.

Should middleware run on the edge runtime?
If it only reads headers, cookies and the URL, yes – it is fast and close to the user. If it needs a database or a Node-only library, move that work into the route instead. Middleware runs on every matched request, so anything slow there is multiplied across your entire traffic.

Is static export a realistic option?
For a documentation site or marketing site with no personalisation, yes, and it is extremely cheap to host. You lose ISR, server actions, route handlers, middleware and built-in image optimisation, so it is a real architectural commitment rather than a build flag.

What breaks first when moving off Vercel?
Image optimisation, usually – it silently becomes CPU on your own containers with no cache in front of it. ISR consistency is the second, once you scale past one instance. Both are fixable, and both are easier to solve before the migration than during it.

← Back to all insights