Deploying Next.js to Vercel
1. First Deployment
Via the Dashboard
- Import the Git repository
- Vercel detects Next.js and fills in the build settings
- Add environment variables
- Deploy
Via the CLI
npm i -g vercel
vercel # deploy a preview
vercel --prod # deploy to production
vercel link # connect a local folder to an existing project
vercel env pull # download env vars into .env.localvercel env pull is the useful one day to day — it keeps your local .env.local in sync with the dashboard.
2. Build Settings
| Setting | Default for Next.js |
|---|---|
| Framework preset | Next.js (auto-detected) |
| Build command | next build |
| Output directory | .next |
| Install command | npm ci (if package-lock.json exists) |
| Node version | Set in project settings or engines in package.json |
Pin Your Node Version
{
"engines": { "node": "20.x" }
}Without this, Vercel may upgrade the default Node version and your build behaves differently than it did last week. Next.js 16 requires Node 20.9+.
Monorepos
Set the Root Directory in project settings (e.g. apps/web). For Turborepo, use turbo build --filter=web as the build command and enable Ignored Build Step so a change to the API package doesn't rebuild the web app.
3. How Next.js Maps to Vercel
| Next.js concept | On Vercel |
|---|---|
Static pages / "use cache" shell | Files on the global CDN |
| Server Components (uncached) | Serverless function |
| Route Handlers | Serverless function |
| Server Actions | Serverless function (POST endpoint) |
proxy.ts / middleware | Edge, runs before everything |
next/image | Vercel's image optimisation service |
| ISR / cached content | Vercel's durable cache, CDN-backed |
/public | CDN |
Reading the build output tells you which route became what:
○ (Static) prerendered
● (SSG) prerendered with generateStaticParams
ƒ (Dynamic) server-rendered on demandAn ƒ where you expected ○ means something opted the route into dynamic rendering — usually cookies(), headers(), or an uncached fetch outside a Suspense boundary.
4. Environment Variables
Vercel has three scopes: Production, Preview, Development.
DATABASE_URL → different value per scope
NEXT_PUBLIC_API_URL → different value per scope
STRIPE_SECRET_KEY → test key for Preview, live key for ProductionThe Two Rules
1. NEXT_PUBLIC_ is public and build-time. It is inlined into the JavaScript bundle. Anyone can read it in DevTools, and changing it in the dashboard does nothing until you redeploy.
2. Everything else is server-only and runtime. Safe for secrets, and a redeploy picks up changes.
Preview Should Not Touch Production Data
Give Preview its own database and its own third-party test keys. A preview deployment is a full working app — if it points at production, a reviewer clicking around can delete real records.
System Variables
process.env.VERCEL_ENV; // "production" | "preview" | "development"
process.env.VERCEL_URL; // this deployment's URL
process.env.VERCEL_GIT_COMMIT_SHA;Useful for tagging errors in Sentry with the exact commit.
5. Caching on Vercel
Next.js 16 flipped caching to opt-in. On Vercel:
"use cache"results are stored in Vercel's durable cache and served from the CDN- Uncached Server Components run per request in a function
revalidateTag/updateTagpurge across the whole CDN, globally
Cache Is Scoped to a Deployment
The cache key includes the build ID, so a new deployment starts with a cold cache. That's deliberate — it stops a new build serving markup produced by the old one.
Practical effect: the first requests after a deploy are slower. Don't mistake it for a regression.
Manual Cache Headers
return NextResponse.json(data, {
headers: { "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300" },
});s-maxage targets the CDN; stale-while-revalidate lets it serve the stale copy while fetching a fresh one in the background. For a Route Handler that returns semi-static data, this is often all you need.
6. Serverless Function Limits
| Limit | Typical value |
|---|---|
| Execution timeout | 10s Hobby, 60s Pro (configurable up to 300s) |
| Memory | 1024 MB default |
| Payload size | 4.5 MB request body |
| Response size | 4.5 MB (streaming exempt) |
| Bundle size | 250 MB unzipped |
export const maxDuration = 60; // per routeWorking Around Them
| Problem | Solution |
|---|---|
| Job takes minutes | A queue (Inngest, QStash, BullMQ on a separate worker) |
| Large file upload | Presigned URL direct to S3/R2 — bypass the function entirely |
| Large response | Stream it |
| Heavy PDF/image generation | A separate service, or a background job |
The presigned URL pattern is worth knowing: the client uploads straight to object storage, and your function only issues the signed URL. No 4.5 MB limit, no function time spent proxying bytes.
7. Database Connections
The failure that catches everyone.
Traffic spike → 500 concurrent function invocations
→ 500 database connections
→ Postgres max_connections = 100
→ "too many clients already"Fixes
| Option | How |
|---|---|
| Connection pooler | PgBouncer, Prisma Accelerate, Neon pooler, Supabase pooler |
| HTTP-based driver | Neon serverless, PlanetScale, Upstash — no persistent TCP |
| Reuse the client | Cache the client on globalThis so warm invocations share it |
// lib/db.ts — prevents a new client per hot reload in dev, and reuses on warm invocations
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const db = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;Important nuance: the globalThis trick helps with warm invocations and dev hot reload. It does not solve the cold-start fan-out — you still need a pooler for real traffic.
8. Rollback
Every deployment has a permanent URL. Production is a pointer.
Dashboard → Deployments → pick a previous one → Promote to Production.
Takes seconds and involves no rebuild — the artifact already exists.
What Does Not Roll Back
Database migrations. Code reverts in seconds; a dropped column does not come back. Run destructive migrations as a separate, later deploy, after the new code has proven stable.
9. Debugging a Failed Deploy
Read the Build Log First
| Error | Cause |
|---|---|
Module not found: './Button' | Case-sensitive imports — Linux builds, macOS/Windows dev. The most common one |
Cannot find module 'x' | Dependency in devDependencies but needed at build |
process.env.X is undefined at build | Env var not set for that scope |
| Type errors | Vercel runs the real build; local next dev skips type checking |
| Out of memory | Large builds — reduce parallelism or upgrade the plan |
Dynamic server usage | A route used cookies()/headers() where a static render was expected |
Runtime Logs
Dashboard → Deployment → Functions → Logs. Or vercel logs <url>.
Reproduce Locally
npm run build && npm start # matches production far better than next devnext dev skips type checking, uses a different bundler configuration, and does not exercise the static/dynamic split. A large share of "works locally, breaks on Vercel" issues are visible with a local production build.
10. Performance on Vercel
The Checklist
- [ ]
next/imagewith correctsizes,priorityon the hero - [ ]
next/font— no external font request, no layout shift - [ ] Static or
"use cache"for anything not personalised - [ ]
"use client"pushed as low in the tree as possible - [ ]
next/dynamicfor heavy browser-only components - [ ] First Load JS reviewed per route in the build output
- [ ] Speed Insights enabled
Compute Near Data
Set the function region to match your database region. A function in iad1 querying a database in iad1 is a millisecond away; the same function in sin1 is 200ms away, per query.
Edge functions are globally distributed, which sounds better and is often worse for anything that touches a database. Use Edge for logic that needs no data access — auth token checks, redirects, geolocation, A/B assignment.
Analytics
import { Analytics } from "@vercel/analytics/react";
import { SpeedInsights } from "@vercel/speed-insights/next";
<body>
{children}
<Analytics />
<SpeedInsights />
</body>Speed Insights reports real-user Core Web Vitals, which is what Google ranks on — not Lighthouse's synthetic run on a simulated device.