Pre-deploy audit, step-by-step deploy walkthrough, and post-deploy verification — with real vercel.json, middleware.ts, and env var examples.
Methodology. Steps below follow the Vercel docs and the Next.js production checklist. The configuration files are extracted from production SaaS deploys on App Router (Next.js 14+) projects. For pricing context see Vercel pricing explained.
Deploying a Next.js SaaS to Vercel is fast, but “fast” is different from “production-ready.” A clean deploy that ships a leaked secret, an unpooled database connection, or a missing security header is worse than no deploy at all. This tutorial walks the full pre-deploy audit, the deploy itself, and the post-deploy verification checklist that catches the issues you shipped without realizing it.
Run this list before you click “Deploy.” Each item takes between two and ten minutes; collectively, they prevent the most common day-one production bugs.
process.env.X usage and confirm each will be set in Vercel. grep -r "process.env" src/ is the fastest way.NEXT_PUBLIC_ is shipped to the browser. Never put service-role keys, API secrets, or webhook secrets behind that prefix.next build locally with production env vars set. Vercel runs the same command — if it fails locally, it will fail on deploy.next lint and tsc --noEmit. Don’t ship a build that you only got green by setting ignoreBuildErrors: true.next.config.js for any external image hosts. Without this, <Image> components fail in production.next.config.js headers or via middleware.ts. Sample below.app/sitemap.ts — generate it dynamically from your routes.app/robots.ts. Don’t accidentally Disallow: / on production.next.config.js/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'avatars.githubusercontent.com' },
{ protocol: 'https', hostname: 'res.cloudinary.com' }
]
},
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }
]
}
];
}
};
module.exports = nextConfig;
middleware.tsimport { NextResponse, type NextRequest } from 'next/server';
export function middleware(req: NextRequest) {
const res = NextResponse.next();
// basic CSP — adjust connect-src for your APIs
res.headers.set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline' https://va.vercel-scripts.com; " +
"style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; " +
"connect-src 'self' https://*.supabase.co https://api.stripe.com;"
);
return res;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)']
};
From the Vercel dashboard, click Add New → Project and select your GitHub repo. Vercel auto-detects the Next.js framework and pre-fills the build command (next build) and output directory. Pick the production branch — usually main — and leave preview deploys enabled for all other branches and pull requests.
If your repo is in a monorepo, set the “Root Directory” to the path of the Next.js app. Vercel will run installs and builds from that directory only. For a comparison of platforms before you commit, see Vercel vs Railway.
Vercel scopes env vars three ways: Production, Preview, and Development. Most secrets should be set on all three; some — like a separate test Stripe key — should only exist in Preview and Development.
# Required for almost every Next.js SaaS NEXT_PUBLIC_SUPABASE_URL=https://xxxxxxxx.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci... SUPABASE_SERVICE_ROLE_KEY=eyJhbGci... # server-side only # Stripe STRIPE_SECRET_KEY=sk_live_... STRIPE_WEBHOOK_SECRET=whsec_... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_... # App NEXT_PUBLIC_SITE_URL=https://www.example.com DATABASE_URL=postgres://user:pass@host:6543/db?pgbouncer=true
Add them via the dashboard (Project Settings → Environment Variables) or with the Vercel CLI:
vercel env add SUPABASE_SERVICE_ROLE_KEY production vercel env pull .env.local # sync down for local dev
Critical: anything ending up in the browser must be prefixed NEXT_PUBLIC_. Anything not meant for the browser must not be. There is no third option.
From Project Settings → Domains, add both the apex (example.com) and the www subdomain. Vercel will show DNS records to add at your registrar — typically an A record on the apex pointing to 76.76.21.21 and a CNAME on www pointing to cname.vercel-dns.com.
Decide which is canonical. Most SaaS pick www because it allows CNAME flattening and avoids cookie-leakage issues across subdomains. In Vercel, set the non-canonical one to redirect to the canonical one.
vercel.json for redirects{
"redirects": [
{ "source": "/login", "destination": "/auth/login", "permanent": true },
{ "source": "/signup", "destination": "/auth/signup", "permanent": true }
],
"headers": [
{
"source": "/api/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "no-store" }
]
}
]
}
Preview deploys are on by default — every PR gets a unique URL like https://your-app-git-feature-branch.vercel.app. Two things to confirm:
App Router uses tagged fetches and revalidatePath/revalidateTag for ISR. Mark cacheable fetches with a tag, then revalidate from a server action when the underlying data changes.
// src/app/blog/[slug]/page.tsx
export const revalidate = 3600; // hourly fallback
export default async function Post({ params }: { params: { slug: string } }) {
const res = await fetch(`${process.env.API_URL}/posts/${params.slug}`, {
next: { tags: [`post:${params.slug}`], revalidate: 3600 }
});
const post = await res.json();
return <article>{/* ... */}</article>;
}
// src/app/actions/publish.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function publishPost(slug: string) {
// ... write to DB
revalidateTag(`post:${slug}`);
}
From Project → Analytics, turn on Web Analytics and Speed Insights. Add the components to your root layout:
// src/app/layout.tsx
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Analytics />
<SpeedInsights />
</body>
</html>
);
}
Web Analytics is free up to a generous monthly event count on Hobby and Pro — enough for most early-stage SaaS. Speed Insights surfaces real-user Core Web Vitals, which is the most useful production performance signal.
Once the deploy is green, work through this list before you call the launch done.
vercel logs --prod for the first hour. Watch for 500s, slow function durations, and any unexpected console output./api/health.sk_, SUPABASE_SERVICE_ROLE, etc. If anything was ever committed, rotate it now.vercel.json) for scheduled tasks. Each cron hits a route handler under app/api/cron/*.pg_dump cron yourself./sitemap.xml.vercel.json{
"crons": [
{ "path": "/api/cron/cleanup-sessions", "schedule": "0 3 * * *" },
{ "path": "/api/cron/usage-report", "schedule": "0 8 * * 1" }
]
}
Cron handlers should verify the Authorization: Bearer ${CRON_SECRET} header that Vercel attaches automatically.
A production-grade Next.js deploy is a 30–60 minute exercise the first time, then 5 minutes after that. The pre-deploy and post-deploy checklists are the part most teams skip — and the part that catches the bugs that would otherwise burn your launch.
The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.