Most "deploy to Vercel" guides stop at hitting the button. Production Vercel is a config you architect. The four files below are what we ship: a vercel.json with headers, redirects and crons; a next.config.ts with the cache strategy spelled out; an edge route that runs auth in under 50ms of first byte; and a Server Action that revalidates the right path when content changes.
1. The vercel.json config
The production config lives in source control. Headers, redirects, crons and rewrites are reviewable, diffable and tied to the commit that introduced them.
// vercel.json
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Strict-Transport-Security",
"value": "max-age=63072000; includeSubDomains; preload"
},
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "Referrer-Policy",
"value": "strict-origin-when-cross-origin"
}
]
},
{
"source": "/_next/static/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
}
],
"redirects": [
{
"source": "/blog/old-slug",
"destination": "/blog/new-slug",
"permanent": true
}
],
"crons": [
{
"path": "/api/cron/reconcile-stripe",
"schedule": "0 3 * * *"
},
{
"path": "/api/cron/refresh-sitemap",
"schedule": "0 4 * * *"
}
]
}
2. The Next.js cache strategy
The next.config.ts makes the cache strategy explicit: ISR for content pages, no caching for authenticated routes, image optimisation tuned to the actual breakpoints the design system uses.
// next.config.ts
import type { NextConfig } from 'next'
const config: NextConfig = {
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [360, 640, 768, 1024, 1280, 1536, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256],
remotePatterns: [
{
protocol: 'https',
hostname: 'images.example.com',
pathname: '/uploads/**',
},
],
},
experimental: {
// Long-lived ISR cache for blog content; bust on demand via webhook.
isrFlushToDisk: true,
},
async headers() {
return [
{
source: '/api/:path*',
headers: [{ key: 'Cache-Control', value: 'no-store' }],
},
]
},
}
export default config
3. The edge route
The auth check runs at the edge in milliseconds. The route reads the session cookie, validates it against a JWT public key (no database round-trip), and either rewrites to the application or redirects to login.
// app/api/auth/check/route.ts
import { NextResponse } from 'next/server'
import { jwtVerify } from 'jose'
export const runtime = 'edge'
const PUBLIC_KEY = process.env.SESSION_PUBLIC_KEY!
export async function GET(request: Request) {
const cookie = request.headers.get('cookie')
const token = cookie?.match(/session=([^;]+)/)?.[1]
if (!token) return NextResponse.json({ ok: false }, { status: 401 })
try {
const { payload } = await jwtVerify(
token,
new TextEncoder().encode(PUBLIC_KEY),
)
return NextResponse.json({ ok: true, userId: payload.sub })
} catch {
return NextResponse.json({ ok: false }, { status: 401 })
}
}
4. On-demand revalidation from a Server Action
When a content editor publishes a post, the Server Action writes to the database and calls revalidatePath for the exact route. The cached version drops; the next request rebuilds and re-caches. No nightly rebuild needed.
// app/admin/posts/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { createServerClient } from '@/lib/supabase/server'
export async function publishPost(postId: string): Promise<{ ok: true } | { ok: false; error: string }> {
const supabase = await createServerClient()
const { data, error } = await supabase
.from('posts')
.update({ status: 'published', published_at: new Date().toISOString() })
.eq('id', postId)
.select('slug, locale')
.single()
if (error) return { ok: false, error: error.message }
// Drop the cache for this exact post and the locale-level index.
revalidatePath(`/${data.locale}/blog/${data.slug}`)
revalidatePath(`/${data.locale}/blog`)
return { ok: true }
}
5. What this buys you
The deploy is a git push. The preview is a URL that shows up on the PR. The auth check answers in 30 milliseconds. The blog cache regenerates the moment an editor presses publish, not on a nightly cron. The bill lands where you expect because every cache decision lives in source control where you can audit it.
The Vercel that gets a bad reputation is the one configured by accident. The Vercel that earns its place is the one architected, with intent, three or four files at a time.