We've deployed enough Next.js apps on Vercel to know which settings matter and which ones you can safely ignore. Some we learned from the docs. Most we learned from things going wrong at inconvenient hours. This post is the guide we wish existed when we started — no filler, just the stuff that's actually made our deployments more reliable.
Set Your Environment Variables Correctly (This One Bites Everyone)
Vercel has three environment scopes: Production, Preview, and Development. Most people dump everything into all three environments and move on. That's fine until you accidentally run Stripe webhooks against your production database from a preview deployment, which is a very specific kind of bad day.
The rule we follow: Production gets real secrets. Preview gets test/staging secrets. Development is usually just your local .env.local anyway, so Vercel's dev scope is mostly for CI workflows. Be explicit about which variables go where.
# Use the Vercel CLI to set env vars per environment
# Don't just click through the dashboard — this is reproducible and reviewable
# Production only
vercel env add STRIPE_SECRET_KEY production
# Preview and Development (test keys)
vercel env add STRIPE_SECRET_KEY preview
vercel env add STRIPE_SECRET_KEY development
# Pull to local .env.local for development
vercel env pull .env.localAlso: never prefix secrets with NEXT_PUBLIC_ unless you actually want them in the browser bundle. We've seen people do NEXT_PUBLIC_DATABASE_URL and I'm not going to pretend that doesn't make me anxious. Anything prefixed with NEXT_PUBLIC ends up in your client-side JavaScript, visible to anyone who opens DevTools.
Configure Your Build Output Carefully
Vercel auto-detects Next.js and does a pretty good job with defaults. But there are a few things in next.config.js worth being intentional about before you hit deploy.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// Generates a standalone build — critical if you ever want to
// self-host or containerize later without re-architecting everything
output: 'standalone',
// Strict image domains — don't use wildcard remotePatterns in production
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'your-s3-bucket.s3.amazonaws.com',
port: '',
pathname: '/uploads/**',
},
],
},
// Helps catch issues before they reach production
typescript: {
ignoreBuildErrors: false, // never set this to true in production config
},
eslint: {
ignoreDuringBuilds: false,
},
}
export default nextConfigThat ignoreBuildErrors: true flag is a trap. We get it — sometimes you're moving fast and TypeScript is yelling about something you'll fix later. But 'later' has a way of becoming 'never', and then you have runtime errors that TypeScript was literally trying to warn you about. Keep the checks on.
Use Preview Deployments as a Real QA Step
Every pull request on Vercel gets its own preview URL. This is genuinely one of Vercel's best features and most teams use it as a glorified 'does it build' check. That's a waste.
We connect preview deployments to a staging database (or a seeded test database) and actually test flows there before merging. Stripe webhooks point to a preview URL during development using the Stripe CLI. The point is: preview deployments should feel like production, just with fake data.
- Share preview URLs with non-technical stakeholders — product managers and clients can review features before merge
- Run Lighthouse against preview URLs in CI to catch performance regressions before they hit main
- Use Vercel's deployment protection (password or Vercel auth) for previews so they're not publicly crawlable
- Set branch-specific env vars if your staging database URL differs from production
The deploy-everything-to-main-and-see-what-happens approach works fine for solo projects. Once you have users, it stops being fine very quickly.
Understand the Edge vs. Node.js Runtime Trade-offs
Vercel lets you run route handlers and middleware on the Edge Runtime instead of Node.js. Edge is faster for cold starts, globally distributed, great for auth checks and redirects. It's also limited — no Node.js APIs, limited npm package support, and some ORMs like Prisma don't fully work there without the Accelerate adapter.
// middleware.ts — good candidate for Edge Runtime
// Fast auth checks, redirects, A/B testing
export const config = {
runtime: 'edge', // This is actually the default for middleware
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const token = request.cookies.get('session')?.value
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
// app/api/heavy-db-query/route.ts — keep this on Node.js runtime
// Prisma, file system access, Node-specific packages
export const runtime = 'nodejs' // explicit, but also the default for route handlersOur rule of thumb: middleware and lightweight auth checks go to Edge. Anything touching a database, doing file I/O, or using a package that has native dependencies stays on Node.js. Don't chase Edge performance for database-heavy routes — the latency is almost always dominated by your DB query, not the function startup.
Set Up Proper Caching Headers (Not Just the Defaults)
Next.js App Router caches aggressively by default. This is great for performance and infuriating when your users are seeing stale data and you can't figure out why. The solution isn't to turn off caching everywhere — it's to be explicit about what you want.
// app/api/user-data/route.ts
// Data that changes per-user — never cache at CDN level
export async function GET(request: Request) {
const data = await getUserData()
return Response.json(data, {
headers: {
'Cache-Control': 'private, no-store',
},
})
}
// app/api/public-pricing/route.ts
// Static-ish data that's the same for everyone — cache aggressively
export async function GET() {
const pricing = await getPricingPlans()
return Response.json(pricing, {
headers: {
// Cache for 1 hour at CDN, serve stale for up to 1 day while revalidating
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
})
}
// For Server Components using fetch:
async function getPublicData() {
const res = await fetch('https://api.example.com/public', {
next: { revalidate: 3600 }, // ISR — revalidate every hour
})
return res.json()
}
async function getUserSpecificData(userId: string) {
const res = await fetch(`https://api.example.com/user/${userId}`, {
cache: 'no-store', // Always fresh
})
return res.json()
}The single most common production bug we see with Vercel deployments: API routes returning cached responses with user-specific data. Always set 'Cache-Control: private, no-store' on anything that could contain user data. Vercel's CDN will cache public routes by default.
Configure Vercel's Speed Insights and Monitoring Early
Vercel has built-in Speed Insights (Core Web Vitals from real users) and Analytics (page view data). They're both worth enabling from day one, not after things are slow. You can't optimize what you didn't measure, and retrofitting observability is always harder than starting with it.
// app/layout.tsx
import { SpeedInsights } from '@vercel/speed-insights/next'
import { Analytics } from '@vercel/analytics/react'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
{children}
{/* These are async, non-blocking — minimal performance impact */}
<SpeedInsights />
<Analytics />
</body>
</html>
)
}The free tier of both is generous enough for most indie projects. Speed Insights shows you real LCP, FID, and CLS scores from actual users broken down by page route. This is infinitely more useful than running Lighthouse locally on your fast laptop on a fast connection.
One thing people miss: Vercel's function logs are retained for a limited time on free plans. If you're building something where you need logs for debugging or compliance, set up a proper logging drain to something like Axiom or Logtail early. We've been caught needing logs from 3 days ago and discovering Vercel only kept 1 day's worth. Set up the drain before you need it.
Handle Secrets Rotation Without Downtime
At some point you'll need to rotate an API key or database password. Most people do this wrong: they update the env var in Vercel and trigger a redeployment, which causes a brief moment where the old code is running with the new key (or vice versa).
The correct pattern: support both old and new secrets simultaneously during rotation, rotate, then clean up. For something like a database password, this means: add the new password to your DB, update the connection string in Vercel but don't redeploy yet, verify the new value is set, then trigger the redeployment. For API keys where the old one gets immediately invalidated, you're accepting a few seconds of errors during the deploy — schedule it during low-traffic hours and make sure your retry logic is solid.
- Use Vercel's 'Sensitive' flag on secrets so they're masked in logs and never shown in the dashboard after saving
- Store non-secret config (feature flags, API URLs) as plain env vars — don't treat everything like a nuclear launch code
- Use team environment variables for shared secrets across multiple projects rather than duplicating them
- Document which env vars are required in your README — future you will thank present you at 2am
Don't Forget Your vercel.json for Advanced Routing
Most Next.js apps on Vercel don't need a vercel.json at all — the framework detection handles everything. But when you do need it, it's worth knowing what's possible.
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "DENY"
},
{
"key": "X-XSS-Protection",
"value": "1; mode=block"
},
{
"key": "Referrer-Policy",
"value": "strict-origin-when-cross-origin"
}
]
}
],
"redirects": [
{
"source": "/old-pricing",
"destination": "/pricing",
"permanent": true
}
],
"functions": {
"app/api/ai-generate/route.ts": {
"maxDuration": 60
}
}
}That security headers block is worth adding to every project. It's five minutes of work that covers you against a bunch of common vulnerabilities. The functions config is useful when you have AI routes or anything that does long processing — the default max duration on Vercel's hobby plan is 10 seconds, which is nothing when you're waiting on an LLM response.
One pattern we've settled on at peal.dev: our templates ship with a vercel.json that includes sensible security headers and any AI route duration overrides pre-configured. It's the kind of thing that's easy to forget to add and annoying to debug when it's missing.
The Checklist Before You Go Live
- Production env vars are set correctly, test keys not leaking into prod scope
- NEXT_PUBLIC_ prefix only on genuinely public config, never on secrets
- Cache-Control headers are explicit on all API routes — especially user-specific ones
- TypeScript and ESLint errors are not suppressed in the build config
- vercel.json has security headers configured
- Speed Insights and Analytics are enabled
- A logging drain is set up if you need more than a day of function logs
- Preview deployments are protected and connected to test/staging data
- Long-running routes (AI, file processing) have maxDuration set appropriately
- You've actually clicked through the app on a preview URL at least once
Vercel is genuinely excellent infrastructure. The work is in being intentional — the platform won't stop you from shipping insecure, uncached, or broken apps. That's still your job.
Most of these practices are things you set up once and forget about. The security headers, the env var structure, the caching strategy — none of it takes more than an hour to get right. But skipping them and fixing the consequences in production takes a lot longer than an hour. We promise.
