Skip to content
Consultation

Migrating a Frontend to Vercel: Next.js Standalone + Docker

Notes from moving this blog's frontend to Vercel: Next.js standalone output, a multi-stage Dockerfile for a lean image, and preview deployments per branch.

Adityo Guni Waluyo4 min read

Why the Frontend Moved to Vercel

This blog originally ran as one monolithic container: the Next.js frontend and the Python API packaged together, built once, run together. That pattern is convenient at first, but it hits a wall quickly. Every small frontend change — a component tweak, a content update — forced a rebuild of the entire stack image, and rollback became an all-or-nothing operation.

Commits in my personal repo last week show the new direction: the frontend was split into its own container image, then migrated to Vercel with a main/staging/api branch model. This article breaks down the migration — why output: 'standalone' is the key, how Docker multi-stage builds keep the image lean, and how the Vercel branch model replaces manual deploy rituals.

output: 'standalone' — the Foundation of a Minimal Deployment

Since Next.js 12, the framework ships output file tracing: during next build, each page's dependencies are statically analyzed via @vercel/nftimport, require, even fs usage — to determine which files production actually needs. As a result, deployments no longer require a full node_modules folder.

Enable it with one line in next.config.js:

module.exports = {
  output: 'standalone',
}

After the build, a .next/standalone folder appears containing the minimal production files plus a server.js you can run without next start. Two folders are deliberately not copied automatically: public and .next/static — both ideally served from a CDN — but you can copy them manually:

cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/
node .next/standalone/server.js

This is exactly the pattern used when splitting the frontend into its own image: the builder runs next build, the runner only copies the standalone output. Dev-dependencies, source maps, and the build toolchain never make it into the final image — the size drops sharply and the attack surface shrinks.

Multi-Stage Builds: Builder Separate from Runner

Multi-stage builds are the Docker idiom for this case: multiple FROM instructions in one Dockerfile, each stage with its own base, and artifacts copied selectively between stages. Everything the runtime does not need — compilers, build tools, temporary dependencies — stays behind in the builder stage.

FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/static ./.next/static
CMD ["node", "server.js"]

Two notes from the official docker-node best practices apply here. First, run the process with NODE_ENV=production — this is also the channel for runtime configuration such as API keys. Second, Node.js was not designed to run as PID 1; pass --init on docker run or use an init wrapper like tini/dumb-init so SIGTERM actually reaches the process. The alternative: bypass the package.json start script and call CMD ["node", "server.js"] directly — exit signals are no longer swallowed by npm. The official node images also ship a node user (uid 1000) if you want the container to run non-root.

The main/staging/api Branch Model on Vercel

Once the image was separate, the next step was moving frontend hosting to Vercel. The chosen model: main for production, staging and api as working branches. Every push to a connected branch triggers a deployment — main becomes the production deployment, other branches become previews with unique URLs.

Two Vercel features make this model comfortable. Monorepo support lets you keep several projects in one repository, each with its own Root Directory; commits that do not touch the frontend directory are skipped automatically, keeping build queues short. Meanwhile Build Settings — framework preset, build command, output directory, Node.js version — can be overridden per project or per deployment via vercel.json. Together they keep per-branch pipelines under control without external CI configuration.

What Happened to the Old Docker Image

Migrating to Vercel does not make the Dockerfile useless. The standalone path is still used for local builds and self-managed staging environments; the only difference now is who runs server.js — your own container or the platform. Splitting the frontend into its own container also left an architectural lesson: next build produces artifacts that are portable by design, so the hosting decision becomes cheap to change. For the stack that still uses Shiki syntax highlighting, the detailed comparison is in the earlier piece on Shiki vs Prism.js.

Wrapping Up

The migration has three layers: output: 'standalone' makes the Next.js artifact minimal and portable, multi-stage builds ensure the running image contains only production runtime, and the Vercel branch model replaces manual deploys with automatic previews. This blog's stack is now: Next.js frontend on Vercel, the API behind it, and content still written and verified against open sources — like the article on embedding YouTube, Mermaid, and Recharts that uses the same tokens. For technical details, go straight to the Next.js output docs and the Docker multi-stage build guide.