| | |

Next.js 17 ▲ Deploying — Vercel deployment & self-hosting prerequisites

⏱️ Estimated read: 12 min | |

Next.js has two deployment paths that share the same build output but differ in what the platform provides. Vercel is the managed path: it detects the framework, configures the build, provisions the CDN and serverless functions, and handles the caching layers automatically. Self-hosting is the do-it-yourself path: the application is built into a standalone directory, copied into a container or a bare-metal host, and served behind a reverse proxy, with the caching, image optimization, and multi-instance coordination configured by the operator. The build artifact is the same; the operational surface is not.

This chapter covers the Vercel deployment model, the self-hosting prerequisites that a production deployment must satisfy, and the configuration options that determine whether a self-hosted deployment behaves like the managed one or diverges from it.

Key point: Self-hosting requires output: 'standalone' in next.config.ts, a Node.js server behind a reverse proxy, and a shared cache handler if more than one instance runs. Without the shared cache handler, ISR and revalidateTag produce inconsistent content across instances.


Why the two paths diverge

The build output problem. Vercel builds the application and deploys the result to its own infrastructure. The build output is not something the developer copies anywhere; Vercel’s build system reads the framework preset, runs next build, and serves the output from its CDN and functions. A self-hosted deployment must produce a portable artifact. The output: 'standalone' option creates that artifact: a minimal .next/standalone directory containing the server entry point (server.js), the production node_modules, and the build output, with the public/ and .next/static/ directories copied separately .

The caching problem. On Vercel, ISR and the Data Cache are shared across the deployment’s infrastructure. A revalidation on one edge node is visible to the others. On a self-hosted single instance, the default filesystem cache works because there is only one filesystem. On multiple instances behind a load balancer, each instance has its own cache, and a revalidation on one instance is invisible to the others. The documentation states this directly: “If you are hosting Next.js using a container orchestration platform like Kubernetes, each pod will have a copy of the cache. To prevent stale data from being shown since the cache is not shared between pods by default, you can configure the Next.js cache to provide a cache handler and disable in-memory caching” .

The encryption problem. Next.js encrypts Server Function closure variables before sending them to the client. By default, a unique encryption key is generated for each build. When running multiple server instances, all instances must use the same key, or a Server Function encrypted by one instance cannot be decrypted by another, producing “Failed to find Server Action” errors. The NEXT_SERVER_ACTIONS_ENCRYPTION_KEY environment variable sets a consistent key across instances .


a. Vercel deployment

The Vercel path is a Git integration. The repository is connected to a Vercel project, and every push to the production branch triggers a build. Vercel detects the Next.js framework, configures the build command (next build), and sets the output directory automatically . The framework preset handles the routing, the CDN, and the serverless functions without manual configuration.

A Vercel project requires Node.js 20.19 or later and a Vercel account. The deployment is triggered from the dashboard or the Vercel CLI. The vercel CLI can be installed globally and used to link a local project to a Vercel project and deploy it .

The platform provides features that a self-hosted deployment must replicate or forgo. Middleware runs globally on the Vercel edge network, with access to geolocation helpers and the NextRequest and NextResponse objects . Draft Mode is secured behind Vercel’s authentication, and the CDN bypasses the ISR cache automatically when Draft Mode is active . The OG image generation library (@vercel/og for the Pages Router, next/og for the App Router) is available without installation, and generated images are cached on the Vercel CDN .

The build configuration on Vercel can be overridden per project or per deployment. The buildCommand and outputDirectory fields in vercel.json override the framework defaults, and the framework field overrides the preset . For most Next.js projects, the defaults are correct, and the overrides exist for monorepos and custom build pipelines.


b. Self-hosting prerequisites

The first prerequisite is the standalone output. The output: 'standalone' option in next.config.ts produces the minimal server bundle. The .next/standalone directory contains the server entry point and the production dependencies, but it does not contain the public/ directory or the .next/static/ directory. Those must be copied into the standalone directory before the server starts .

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  output: 'standalone',
}

export default nextConfig

The second prerequisite is the runtime. The standalone server runs on Node.js. A Dockerfile uses a Node base image, copies the standalone output, copies the static assets, and runs node server.js with NODE_ENV=production and PORT and HOSTNAME set . The server binds to the port, and a reverse proxy (Nginx, Caddy, or a cloud load balancer) terminates TLS and forwards traffic to it.

The third prerequisite is the reverse proxy. The proxy handles TLS termination, compression, and the static asset caching headers. The Next.js documentation notes that immutable assets are served with Cache-Control: public, max-age=31536000, immutable, and the proxy should respect that header. ISR responses carry s-maxage and stale-while-revalidate, and the CDN or proxy must respect those directives for the caching to work at the edge .

The fourth prerequisite is the shared cache handler if more than one instance runs. The handler implements get, set, revalidateTag, and resetRequestCache, and it stores entries in a location that every instance can reach, such as Redis or S3. The entries must be serialized, because they can contain Buffer and Map values that JSON cannot round-trip. The entries must be indexed by tag, so that a revalidateTag call on one instance invalidates the entry on every instance. The cacheMaxMemorySize: 0 option disables the default in-memory cache so that the handler owns the caching behavior .

// next.config.js
module.exports = {
  cacheHandler: require.resolve('./cache-handler.js'),
  cacheMaxMemorySize: 0,
}

The fifth prerequisite is the Server Actions encryption key. A multi-instance deployment sets NEXT_SERVER_ACTIONS_ENCRYPTION_KEY to a consistent base64-encoded key. A single-instance deployment does not need it, because the key generated at build time is used by the single instance .

The sixth prerequisite is the build ID. When rebuilding for each stage of an environment and booting multiple containers, the build ID must be consistent. The generateBuildId option in next.config.js sets a consistent ID, such as the Git commit hash .


Complete Example Session

// ============================================
// PART 1: THE STANDALONE OUTPUT
// ============================================
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  output: 'standalone',
}

export default nextConfig

// The build produces .next/standalone/server.js.
# ============================================
# PART 2: THE DOCKERFILE
# ============================================
FROM node:20-alpine AS base

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

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

RUN addgroup --system --gid 1001 nodejs
RUN 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"]
# ============================================
# PART 3: THE REVERSE PROXY
# ============================================
upstream nextjs_app {
    server 127.0.0.1:3000;
}

server {
    listen 443 ssl http2;
    server_name example.com;

    location / {
        proxy_pass http://nextjs_app;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location /_next/static {
        alias /app/.next/static;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}
// ============================================
// PART 4: THE CACHE HANDLER
// ============================================
// next.config.js
module.exports = {
  cacheHandler: require.resolve('./cache-handler.js'),
  cacheMaxMemorySize: 0,
}
// ============================================
// PART 5: THE CACHE HANDLER IMPLEMENTATION
// ============================================
const cache = new Map()

module.exports = class CacheHandler {
  async get(key) {
    return cache.get(key)
  }

  async set(key, data, ctx) {
    cache.set(key, { value: data, lastModified: Date.now(), tags: ctx.tags })
  }

  async revalidateTag(tags) {
    tags = [tags].flat()
    for (const [key, value] of cache) {
      if (value.tags.some((tag) => tags.includes(tag))) {
        cache.delete(key)
      }
    }
  }

  resetRequestCache() {}
}
# ============================================
# PART 6: THE ENCRYPTION KEY
# ============================================
# For multi-instance deployments:
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=base64-encoded-key

# The key must be the same on every instance.
// ============================================
// PART 7: THE BUILD ID
// ============================================
module.exports = {
  generateBuildId: async () => {
    return process.env.GIT_HASH
  },
}

// Consistent across containers.
# ============================================
# PART 8: THE BUILD AND RUN
# ============================================
npm run build
docker build -t myapp .
docker run -p 3000:3000 -e NODE_ENV=production myapp
# ============================================
# PART 9: THE VERCEL DEPLOYMENT
# ============================================
npm i -g vercel
vercel
# Follow the prompts to link and deploy.
// ============================================
// PART 10: THE VERCEL OVERRIDE
// ============================================
// vercel.json
{
  "buildCommand": "npm run build",
  "outputDirectory": ".next"
}

The ten parts covered the standalone output, the Dockerfile, the reverse proxy, the cache handler configuration, the cache handler implementation, the encryption key, the build ID, the build and run, the Vercel deployment, and the Vercel override.


Quick Reference

Vercel vs self-hosting

ConcernVercelSelf-hosting
BuildAuto-detectedManual next build
OutputManagedoutput: 'standalone'
CDNBuilt-inReverse proxy / CDN
ISR cacheSharedShared only with handler
Server Functions keyManagedNEXT_SERVER_ACTIONS_ENCRYPTION_KEY
Image optimizationBuilt-inBuilt-in or external loader

Self-hosting prerequisites

PrerequisiteDetail
Standalone outputoutput: 'standalone'
RuntimeNode.js, node server.js
Reverse proxyTLS termination, static caching
Shared cache handlerFor multi-instance ISR
Encryption keyFor multi-instance Server Functions
Build IDFor multi-container consistency

Cache handler requirements

RequirementReason
Shared storageRedis, S3, or similar
SerializationBuffer and Map values
Tag indexingrevalidateTag across instances
Error handlingTimeouts, dropped connections
Size boundingTTL or eviction policy

Docker copy order

SourceDestination
/app/public./public
/app/.next/standalone./
/app/.next/static./.next/static

Best Practices

✅ Do This:

// Use standalone output for self-hosting
output: 'standalone'                                          // ✅
// Configure a shared cache handler for multi-instance
cacheHandler: require.resolve('./cache-handler.js')           // ✅
# Set a consistent encryption key across instances
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=...                        // ✅
# Copy static and public separately
COPY --from=builder /app/.next/static ./.next/static          // ✅

❌ Don’t Do This:

# Forget to copy the static directory
# Assets return 404                                            // ❌
// Use the default filesystem cache with multiple instances
// ISR produces inconsistent content                           // ❌
# Rebuild for each container without a consistent build ID
# The build ID differs across containers                      // ❌

Common Pitfalls

PitfallWhy It HappensFix
Static assets return 404.next/static not copiedCopy it in the Dockerfile
ISR inconsistent across podsFilesystem cache per instanceUse a shared cache handler
Server Action fails across instancesEncryption key differsSet NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
Build is slow or OOMsInsufficient RAMUse a host with at least 2 GB for the build
Vercel build failsWrong framework preset or output directoryCheck the project settings

Real-World Examples

1. Standalone config

output: 'standalone'

2. Dockerfile copy

COPY --from=builder /app/.next/standalone ./

3. Cache handler config

cacheHandler: require.resolve('./cache-handler.js'), cacheMaxMemorySize: 0

4. Encryption key

NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=...

5. Build ID

generateBuildId: async () => process.env.GIT_HASH

6. Nginx static caching

location /_next/static { alias /app/.next/static; expires 1y; }

7. Vercel CLI

vercel --prod

8. Vercel override

{ "buildCommand": "npm run build" }

9. Health check

curl http://localhost:3000/api/health

10. Multi-instance warning

Each pod has its own cache unless a shared handler is configured.

Visual

The two deployment paths

┌─────────────────────────────────────────────────────────────┐
│  VERCEL                          SELF-HOSTING                │
│                                                              │
│  Git push ──► Vercel build       Git push ──► CI build      │
│       │                              │                      │
│       ▼                              ▼                      │
│  Auto-detected config            output: 'standalone'       │
│       │                              │                      │
│       ▼                              ▼                      │
│  Vercel infrastructure           Docker image / bare metal  │
│       │                              │                      │
│       ▼                              ▼                      │
│  CDN + functions                 Reverse proxy + Node       │
└─────────────────────────────────────────────────────────────┘

The standalone output

┌─────────────────────────────────────────────────────────────┐
│  .next/standalone/                                           │
│    ├── server.js                                             │
│    ├── node_modules/                                         │
│    └── .next/                                                │
│                                                              │
│  Must copy separately:                                       │
│    ├── public/                                               │
│    └── .next/static/                                         │
└─────────────────────────────────────────────────────────────┘

The multi-instance cache problem

┌─────────────────────────────────────────────────────────────┐
│  WITHOUT SHARED CACHE                                        │
│    Instance A revalidates ──► local cache updated            │
│    Instance B serves stale ──► no visibility                │
│    Load balancer alternates ──► inconsistent content         │
│                                                              │
│  WITH SHARED CACHE                                           │
│    Instance A revalidates ──► Redis updated                  │
│    Instance B revalidates ──► reads Redis                    │
│    Consistent across instances                               │
└─────────────────────────────────────────────────────────────┘

The encryption key

┌─────────────────────────────────────────────────────────────┐
│  Build 1: key A ──► Instance 1 encrypts closure              │
│  Build 2: key B ──► Instance 2 cannot decrypt               │
│                                                              │
│  Set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY to the same value   │
│  on every instance.                                          │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
Vercel buildAuto-detected, Git integration
Vercel outputManaged, not portable
Self-host outputoutput: 'standalone'
Standalone entry.next/standalone/server.js
Static assets.next/static/ copied separately
Multi-instance cacheShared cache handler
Cache handler configcacheHandler + cacheMaxMemorySize: 0
Multi-instance encryptionNEXT_SERVER_ACTIONS_ENCRYPTION_KEY
Build IDgenerateBuildId

Key takeaways:

  • Vercel is the managed path; self-hosting is the portable path. Vercel detects the framework, configures the build, and handles the CDN and caching. Self-hosting requires the output: 'standalone' option, a Node.js server, and a reverse proxy .
  • The standalone output is minimal but incomplete. The .next/standalone directory contains the server and the production dependencies, but the public/ and .next/static/ directories must be copied separately. A Dockerfile that omits them produces a server that returns 404 for every static asset .
  • Multi-instance self-hosting requires a shared cache handler. The default filesystem cache is per-instance, so ISR and revalidateTag produce inconsistent content across pods. The handler stores entries in Redis or S3, serializes the Buffer and Map values, and indexes them by tag .
  • Multi-instance self-hosting requires a consistent encryption key. Server Functions encrypt closure variables with a build-specific key. All instances must use the same key, set through NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, or the Server Action fails with “Failed to find Server Action” .
  • A consistent build ID keeps multiple containers on the same build. The generateBuildId option sets the ID, and using the Git commit hash ensures that every container boots the same version .
  • The Vercel build is configurable but usually does not need to be. The framework preset handles the build command and output directory. The vercel.json overrides exist for monorepos and custom pipelines .

Remember: Deployment is a choice between the managed platform and the portable artifact. Vercel provides the CDN, the functions, and the caching without configuration. Self-hosting provides control and cost predictability, but it requires the standalone output, the reverse proxy, the shared cache handler, and the encryption key. The build artifact is the same; the operational surface is not. Get the standalone output and the static asset copy right first, because a server without its static files is a broken deployment. Then add the shared cache handler and the encryption key if more than one instance runs, because a multi-instance deployment without them is an inconsistent one.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!