The app that runs perfectly on your laptop and the app that fails in production are usually the same code. What differs is everything around the code: which database it talks to, which secrets it can read, how many connections it is allowed to open, which domain serves it. Hosting platforms market themselves on making that gap disappear. Vercel makes it small. It does not make it zero, and the remaining part is yours.
The example throughout is the boring, standard stack: Next.js with the App Router, Prisma, and a managed Postgres from a provider like Neon or Supabase. If your stack differs in the details, the principles do not.
Start by connecting the repository, not by running deploy commands. In the Vercel dashboard, import your GitHub repo. Vercel detects Next.js, fills in the build command, and from that moment the rule is simple: every push to main becomes a production deployment, and every push to any other branch becomes a preview deployment with its own URL. The CLI still earns its place — mostly for linking the project and pulling environment variables down to your machine:
# one-time setup
npm i -g vercel
vercel login
vercel link # connects this folder to the Vercel project
vercel env pull .env.local # syncs dashboard vars to your machine
# manual deploys, when you genuinely need one
vercel # preview deployment
vercel --prod # production deploymentResist the habit of deploying from your laptop with vercel --prod. It works, but it bypasses the history that makes Git-based deployments auditable. The dashboard shows exactly which commit produced which deployment; a CLI deploy shows a snapshot of whatever happened to be in your working directory, half-finished changes included.
Secrets never go in git. Everyone knows this, and everyone is one careless commit away from rotating their database password anyway. The discipline that actually holds is structural, not willpower: a committed .env.example that documents every variable the app needs, a gitignored .env.local that holds your real local values, and the Vercel dashboard as the single source of truth for production.
# Committed to git. Real values live in Vercel, never here.
DATABASE_URL= # pooled connection string (port 6543 on Supabase)
DIRECT_URL= # direct connection, migrations only (port 5432)
AUTH_SECRET= # generate with: npx auth secret
CRON_SECRET= # random string, protects cron endpoints
RESEND_API_KEY=
NEXT_PUBLIC_SITE_URL=http://localhost:3000Vercel scopes every variable to one or more of three environments: Production, Preview, and Development. This is not bureaucracy. It is what lets preview deployments use a staging database while production uses the real one, under the same variable name. Set DATABASE_URL once for Production and once for Preview, pointing at different databases, and your code never has to know the difference.
warning
The classic first failure: the build passes locally and dies on Vercel complaining about an undefined environment variable. Locally you have .env.local; Vercel only has what you put in the dashboard. And NEXT_PUBLIC_ variables are inlined into the JavaScript bundle at build time — adding one after the build has finished changes nothing until you redeploy.
On your laptop, the app is one long-running process holding a handful of database connections. On Vercel, every request can be served by a separate short-lived function instance, and each instance opens its own connections. Under even modest load — a small spike from a newsletter send, say — you can have dozens of instances alive at once. A free-tier managed Postgres allows somewhere in the neighbourhood of a few dozen direct connections. The arithmetic ends badly, and it ends in production, never in development.
The fix is connection pooling: a proxy such as PgBouncer, or your provider's built-in pooler, sits in front of Postgres, holds a small set of real connections, and lends them out per transaction. Supabase exposes its pooler on port 6543 next to the direct connection on 5432; Neon gives you a separate pooled hostname. Your serverless functions talk to the pooler. Only migrations — which need session-level features the pooler does not support — talk to the database directly. Prisma encodes this split as two URLs:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL") // pooled — what your functions use
directUrl = env("DIRECT_URL") // direct — what migrations use
}The second half of the fix is making sure your own code does not multiply clients. In development, Next.js hot-reloads modules, and a naive new PrismaClient() at module scope creates a fresh client — with a fresh connection pool — on every reload. The standard singleton guards against it:
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}warning
If you skip pooling, everything works in development and through your first quiet week of production. Then traffic arrives and the logs fill with "FATAL: sorry, too many clients already". The bug is not in your code, and restarting fixes nothing for long. Configure the pooler before launch, not during the incident.
Every pull request gets a complete deployment at a unique URL: same build process, same serverless infrastructure, same edge network as production. The first time you see one, it registers as a nice-to-have. Then it quietly changes how you work.
- You test on a real phone over a real network, not a resized desktop browser window
- You send the URL to a non-technical person and get feedback before merging, not after shipping
- OAuth callbacks, webhooks, and anything else that refuses to run against localhost gets exercised on a real HTTPS URL
- The build passes or fails per pull request, so a broken build cannot quietly reach main
What makes previews safe is the environment scoping from earlier: preview deployments read Preview-scoped variables. Point those at a staging database — Neon's database branching makes this almost free — and a half-finished pull request can never write into production data. Skip that step and previews become a way to corrupt production from a branch, which is worse than not having them.
A custom domain is mostly a DNS exercise. Add the domain under the project's settings and Vercel tells you exactly what it wants: an A record on the apex (yourdomain.com) pointing at Vercel's IP, and a CNAME on www pointing at cname.vercel-dns.com. Pick one of the two as canonical — Vercel redirects the other automatically — and let it issue and renew the TLS certificate for you. DNS propagation is the only real wait: usually minutes, occasionally hours, never worth panicking about on day one.
Cron jobs cover the work no request triggers — sending a weekly digest, cleaning expired sessions, refreshing a cache. On Vercel a cron job is a line of configuration plus an ordinary route:
{
"crons": [
{ "path": "/api/cron/send-digest", "schedule": "0 7 * * 1" }
]
}export async function GET(request: Request) {
const auth = request.headers.get("authorization");
if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response("Unauthorized", { status: 401 });
}
await sendWeeklyDigest();
return Response.json({ ok: true });
}The authorization check is not optional. A cron endpoint is just a URL, and anyone who finds it can hit it. When you define a CRON_SECRET environment variable, Vercel sends it as a bearer token with every scheduled invocation — verify it before doing any work.
Rollback is the most underappreciated part of the whole platform. Every production deployment is immutable and kept. When a bad deploy reaches production, you open the deployments list, pick the previous one, and hit Instant Rollback — or promote any older deployment. There is no rebuild and no frantic git revert; the domain simply points at the old build within seconds. The one thing it cannot undo is a database migration. If the bad deploy changed your schema, rolling back the code does not roll back the data. Treat migrations as forward-only, and write each one so the previous version of the code can still run against the new schema.
Now the honest part. Vercel's Hobby tier is genuinely generous for learning and side projects, but it has two hard edges. The first is the license: Hobby is for non-commercial use, so the moment your project earns money you are expected to move to Pro, at roughly twenty dollars per seat per month. The second is the metering. Bandwidth, function execution, and — the one that surprises people — image optimization are all counted. Next's Image component routes every picture through Vercel's optimizer, and the free allowance is measured in optimized source images; a media-heavy site can exhaust it with entirely normal traffic. The workarounds are unglamorous: optimize images at build time, serve them from a separate host, or set unoptimized and handle sizing yourself.
Lock-in is real, but smaller than the fear of it. The Next.js app itself is portable: it runs in a Docker container on any VPS, and the OpenNext project deploys it to AWS or Cloudflare. What is not portable is the convenience layer — preview deployments, cron configuration, instant rollback, the image optimizer, the analytics. Leaving Vercel does not mean rewriting your app; it means rebuilding that layer with your own CI, your own reverse proxy, your own scheduler. That is a real cost, and it is precisely why the platform can charge you for not paying it. My position: stay until the bill or the limits genuinely hurt, and keep your application code free of Vercel-only APIs in the meantime, so the door stays open.
Before your first push to main, run down this list. Each line is a production incident you will not have.
- .env.example committed and complete; .env and .env.local gitignored
- Every variable from .env.example exists in Vercel, scoped to Production and Preview
- DATABASE_URL points at the pooled connection, DIRECT_URL at the direct one
- Prisma client is the singleton from lib/prisma.ts, not a fresh new PrismaClient() per module
- Preview environment points at a staging or branched database, never at production
- npm run build passes locally — Vercel will not be more forgiving than your machine
- Cron routes verify CRON_SECRET before doing any work
- Domain added, apex and www both resolving, one redirecting to the other
- You know where the Instant Rollback button is before you need it
- No secret has ever been committed — and if one was, it is rotated, not merely deleted
Then deploy — early, not at the end. The single best habit you can build from this guide is making deployment the first milestone of a project instead of the last: get a skeleton to production in week one, and every feature after that ships into an environment you already trust. The configuration discipline above takes one afternoon to set up. Debugging its absence on launch day takes a weekend you will not enjoy, with an audience watching.
