Deploying a Next.js SaaS to Production on Vercel: The Complete Checklist
From environment variable setup to post-deploy monitoring: everything to check before your SaaS is actually ready for production.
Deployment isn't a git push
Technically, Vercel deploys your app on every push to main. In practice, there are about 20 things to check before your SaaS is genuinely ready for production.
Here's the full checklist HeartCo uses.
1. Environment variables
# Database
DATABASE_URL="postgresql://..."
DIRECT_URL="postgresql://..." # For Prisma migrations
# Auth
NEXTAUTH_URL="https://your-app.com"
NEXTAUTH_SECRET="openssl rand -base64 32"
# Stripe
STRIPE_SECRET_KEY="sk_live_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_live_..."
# Email
RESEND_API_KEY="re_..."
# AI
MISTRAL_API_KEY="..."The golden rule
Never put secrets in your code or a committed .env. Use Vercel's Environment Variables, with different values for Preview and Production.
2. Vercel configuration in a monorepo
// apps/web/vercel.json
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": "nextjs",
"regions": ["cdg1"],
"installCommand": "cd ../.. && pnpm install",
"buildCommand": "cd ../.. && npx turbo build --filter=@heartco/web",
"outputDirectory": ".next",
"ignoreCommand": "bash vercel-ignore.sh"
}Two details that matter in a pnpm/Turborepo monorepo: installCommand and buildCommand both cd up to the repo root first, since pnpm install and Turbo need to see the whole workspace, not just this one app's folder. And outputDirectory stays a plain .next, relative to this app, because the Vercel project's Root Directory setting is already pointed at apps/web.
ignoreCommand is the real cost lever here: a small script that tells Vercel to skip a preview build entirely when nothing in this app's scope changed, so pushing a feature branch to a monorepo with two other deployable apps doesn't also trigger two unrelated preview builds on top of it. Production builds on main are never skipped.
3. Database
Migrations
# Locally, create the migration
npx prisma migrate dev --name init
# In production, apply it
npx prisma migrate deployImportant
migrate dev is for development (creates + applies). migrate deploy is for
production (applies only).
Connection pooling
Supabase gives you two URLs:
- Transaction mode (
?pgbouncer=true) → forDATABASE_URL(regular queries) - Session mode → for
DIRECT_URL(migrations)
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}4. Domain and SSL
- Add your domain under Vercel → Settings → Domains
- Configure DNS with your registrar (CNAME to
cname.vercel-dns.com) - Vercel provisions the SSL certificate automatically (Let's Encrypt)
- Add the
www→ apex redirect (or the other way around)
5. Security headers, and what's deliberately not a static header
// next.config.js
const securityHeaders = [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-XSS-Protection", value: "0" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
];Notice X-XSS-Protection is explicitly set to "0", not the older "1; mode=block" you'll find in a lot of tutorials: that legacy header has its own history of introducing XSS vectors in older browsers and is superseded by a real Content-Security-Policy.
One header you won't find in this static list: Content-Security-Policy. It's deliberately absent here and injected per-request instead, from middleware, with a fresh nonce on every request. A static CSP header would overwrite that nonce and silently break script-src protection — the kind of bug that only shows up as a broken production page, long after the header looked "done" in a config file.
6. Scheduled jobs: why not just Vercel's own crons
// A generic vercel.json crons array
{
"crons": [
{ "path": "/api/cron/reset-usage", "schedule": "0 0 1 * *" },
{ "path": "/api/cron/trial-expiry", "schedule": "0 8 * * *" }
]
}That works, and it's the simplest option if you only need a handful of jobs. It stops being enough once you have close to twenty of them (freemium resets, trial reminders, recurring invoices, calendar reminders, dunning, demo-data resets...): Vercel's native cron scheduler has real limits on count and minimum frequency per plan tier.
HeartCo schedules its ~18 cron jobs through Upstash QStash instead, and every handler accepts either QStash's own signed request or a manual trigger, so the same route works whether it's invoked on schedule or by an admin:
// src/lib/cron-auth.ts (simplified)
export async function verifyCronRequest(req: NextRequest): Promise<boolean> {
const qstashSignature = req.headers.get("upstash-signature");
if (qstashSignature) {
const receiver = new Receiver({
currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY!,
nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!,
});
const body = await req.clone().text();
return await receiver.verify({ signature: qstashSignature, body });
}
// Fallback: a Bearer secret, compared in constant time
const expected = `Bearer ${process.env.CRON_SECRET}`;
const authHeader = req.headers.get("authorization") ?? "";
return (
authHeader.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(authHeader), Buffer.from(expected))
);
}Every handler starts the same way: if (!(await verifyCronRequest(req))) return cronUnauthorized();. Whether you reach for Vercel's native crons or an external scheduler like QStash, the one rule that doesn't change is the constant-time comparison on the fallback secret.
7. Monitoring and alerts
Vercel Analytics, gated by consent
// app/layout.tsx
import { ConsentGatedAnalytics } from "~/components/legal/consent-gated-analytics";
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
{/* Vercel Analytics + Speed Insights — only after cookie consent */}
<ConsentGatedAnalytics />
</body>
</html>
);
}Dropping <Analytics /> and <SpeedInsights /> straight into the root layout is the fastest path, but it also means they start firing before a visitor has said yes to anything. Wrapping them behind a consent check costs one extra component and keeps analytics honest with whatever cookie banner you're already showing.
Structured logs
// Always log context, not just the error
console.error("[STRIPE_WEBHOOK]", {
eventType: event.type,
organizationId: orgId,
error: error.message,
});Final checklist before go-live
Infrastructure
- Environment variables configured (Production ≠ Preview)
- Database migrated (
prisma migrate deploy) - Domain configured + SSL active
- Scheduled jobs configured and authenticated
Security
- Security headers in place
-
NEXTAUTH_SECRETgenerated (not a default value) - Stripe webhook verified by signature
- No secrets in the source code
Performance
- Images optimized (
next/image) - Fonts optimized (
next/font) - Bundle analyzed (
@next/bundle-analyzer)
Functional
- Sign up → log in → dashboard works
- Stripe payment works (live mode)
- Transactional emails arrive
- Dark mode works
Monitoring
- Vercel Analytics enabled (consent-gated)
- Error tracking configured (Sentry recommended)
- Uptime monitoring (Vercel / Better Uptime)
A successful deployment isn't the moment the app runs: it's the moment it runs, takes payments, sends emails, and tells you the instant something breaks.
Go further
Related articles
Ready to launch your SaaS?
HeartCo Starter includes everything you need: auth, payments, AI, mobile, audited security. Starting at $219.