A Neon branch per preview
Wire Neon copy-on-write branching and the Vercel-Neon integration so every PR preview gets its own production-shaped, isolated database.
Every PR gets its own preview URL, but each one reads the same DATABASE_URL as production: the same connection string, the same Postgres, the same rows your real customers live in.
That is not a theoretical risk.
A teammate clicks through a preview, signs up a fake account, and it lands in production.
A reviewer opens a PR that adds a column, and the preview either alters the production schema or crashes because the live schema no longer matches the code.
A PR ships a cleanup script that wipes test data, runs it against the preview’s DATABASE_URL, and real data is gone with no undo.
This lesson fixes that. By the end, every PR gets its own private copy of the production database, created when the PR opens and torn down when it closes. The goal in one sentence: every PR should run against a database shaped like production that isn’t production.
Previews share your production database
Section titled “Previews share your production database”Today, four environments read and write one database: production, serving your customers, and every open PR’s preview, each a working copy of the app that reads and writes the exact rows production does.
Two fixes look obvious, and seeing why each fails points to the real answer.
The first is a single shared staging database, one extra Postgres that every preview points at instead of production. That protects customers, but every preview still shares one database with every other preview, so PR #41’s migration breaks PR #42’s app, and two reviewers testing at once overwrite each other’s data. The collision has moved, not gone.
The second is provisioning a fresh Postgres per PR from a CI script. That gives real isolation, but copying production’s data into a new database costs minutes and money on every push. A repo with a dozen open PRs pays to keep a dozen full copies of its data alive.
So the requirement sharpens to three properties: per-PR databases that are instant to create, near-free to keep around, and fully isolated. Hit all three and a database per PR becomes the obvious default; miss one and you are back to a compromise. Meeting that trio is why this course put your database on Neon.
How Neon branching works
Section titled “How Neon branching works”The one new idea here is Neon’s copy-on-write branching. The rest of the lesson is wiring that follows from it.
Neon’s key move is separating storage from compute. Your data lives in one storage layer, and the database engine that reads and writes it sits on top as a separate thing. Once the two are decoupled, something a normal Postgres can’t do becomes possible: you can make a second database that points at the same stored data without copying any of it.
That pointer is a branch , and its behavior unfolds in four stages.
Stage one: a branch is a pointer at a snapshot. When you branch off your production database, Neon copies no rows. It records a snapshot , a marker that says “this branch starts from the parent’s data as it looked right now,” and stops there. Creating the branch is instant whether the database holds ten rows or ten million, because nothing is duplicated.
Stage two: reads serve from the parent. The instant after you create the branch, querying it returns all of production’s data. The branch holds none of its own yet, so every read falls through to the shared parent storage. An untouched branch is essentially free to keep around.
Stage three: the first write diverges. Write a row on the branch and you hit the copy-on-write moment. Neon copies just the small chunk of storage that changed, the page that row lives on, and the branch now owns that one chunk; everything else still falls through to the parent. From here the branch is fully isolated: the write exists only on the branch, and the parent can change underneath without the branch noticing. You get complete isolation having copied only the bytes that actually differ.
Stage four: main is just the production branch.
Neon calls your primary database the main branch, and production runs on it.
Every preview branches off main, so a preview starts not empty and not with fake fixtures, but as an exact snapshot of production’s data, then diverges only as the PR’s code touches it.
Scrub through the four stages below and watch how little actually gets duplicated.
copied →
This delivers the exact trio the previous section asked for: a Neon branch is instant to create, near-free to keep idle until it’s written to, and fully isolated the moment it diverges. No other Postgres host hands you this as a built-in feature in 2026; a self-hosted Postgres or a managed box like Amazon RDS leaves you with a CI-script full-copy database per PR or a shared staging database, the slower, less-isolated options already ruled out. That’s why a database per PR is suddenly practical, and why this course’s Postgres lives on Neon.
Wiring the Native Vercel-Neon Integration
Section titled “Wiring the Native Vercel-Neon Integration”The connective tissue is the Native Vercel-Neon Integration, an official integration that teaches Vercel and Neon to talk to each other. Once installed, every preview deployment triggers Neon to create a branch, and Vercel injects that branch’s connection string into the deployment. You do this once, through the Vercel dashboard, and never touch it again.
-
In your Vercel dashboard, open Integrations, then Browse Marketplace.
-
Find and select Neon, then click Install.
-
Choose the Vercel project you’re pairing, which is your app.
-
Authorize Neon when prompted. This lets Vercel and Neon exchange the connection details on your behalf.
-
Pick the Neon project to pair with: the one holding your production
mainbranch from when you set up the database.
The integration adds a DATABASE_URL to your Preview environment and marks it as a managed variable .
A small lock icon appears next to it, and Vercel won’t let you edit its value.
That’s deliberate: each preview gets a different branch, so the value is meant to change on every deployment.
It leaves your other two databases alone.
Your production DATABASE_URL stays pointed at main, and your local development database is untouched.
The integration owns one variable in one environment, the Preview DATABASE_URL, and nothing else.
Here is what now happens on each PR:
- A preview deploys, and Neon creates a branch off
mainnamed after the git branch, something likepreview/add-export-button. - Vercel injects that branch’s connection string into that deployment’s
DATABASE_URL, scoped per-deployment: PR #41’s latest commit wires its branch only into PR #41’s latest preview. - When the PR merges or closes, the branch is deleted automatically.
During install you may have been asked to choose between Neon-Managed and Vercel-Managed. For preview branching they behave identically; the difference is who provisions and bills Neon. Vercel-Managed runs Neon through Vercel, convenient when you’re starting completely fresh. This course uses Neon-Managed for continuity: you set Neon up directly when you built the data layer, so keeping it as the single source of truth means you don’t fork your billing or dashboards. Pick the one that matches where your database already lives.
Migrating the preview branch before the app boots
Section titled “Migrating the preview branch before the app boots”A preview branch starts as a copy of production, schema included, not just rows. But the point of a PR is often a schema change: a new column, table, or index. The PR’s code expects that new shape, while the branch was forked from production, which doesn’t have it yet. So the app boots against a database one step behind its own code, and you get a runtime error the instant anything touches the missing column.
The fix is to run the PR’s migrations against the preview branch before the app boots. The branch starts with production’s schema, the build brings it up to the PR’s schema, then the app starts against a database that matches.
The hook for “run something before the app boots” is the Build Command. By default it runs only the framework’s build; you extend it to run your migrations first.
next buildThe preview boots on a branch that still has production’s schema. If the PR added a column, the app throws the first time it queries that column. The branch is a perfect copy of production, including the schema the PR was supposed to change.
pnpm db:migrate && next buildMigrations run first, against this deployment’s DATABASE_URL, which the integration already pointed at the preview branch. The branch’s schema is brought up to the PR’s schema, then the app builds and boots against a database that matches its code. A prebuild script in package.json achieves the same thing if you prefer to keep the dashboard field clean.
The migrate script is small.
It reads DATABASE_URL from the environment, which is the key to the whole thing: the script automatically targets whatever branch this deployment was handed, with no per-environment configuration.
It applies the migration files already committed in drizzle/, using the unpooled database client.
import { migrate } from 'drizzle-orm/postgres-js/migrator';import { dbUnpooled } from '@/db/index';
await migrate(dbUnpooled, { migrationsFolder: './drizzle' });It uses dbUnpooled, not the default pooled db, on purpose: a migration is a sequence of statements that must run in order on one connection, and a pool hands out whatever connection is free, the wrong tool for an ordered, stateful job.
The dbUnpooled export has been in your project since the data layer for exactly this.
And db:migrate, the script the Build Command calls, is just the package.json entry that runs this file.
One point is a discipline, not a convenience: the build only ever applies migrations that already exist.
You generate the migration, review the SQL it produced, then apply it, and the preview build runs only that last step.
It never generates a new migration and never pushes schema changes to a database without a reviewed file.
The migration files in drizzle/ are the contract; the build just runs them.
Putting migrations in the Build Command means every deploy runs them, including production deploys against main’s database.
For previews that’s exactly right.
For production it’s dangerous: a naive destructive migration running automatically on every push to main is how you delete a column out from under live traffic with no human in the loop.
So the senior call: migrations in the Build Command are fine for previews, but production migrations belong behind a gated, explicitly approved CI step.
A preview branch is disposable, so an automatic migration against it costs nothing if it’s wrong.
The main branch is your customers’ data, so a migration against it should earn a human’s approval first.
The next chapter owns that full story.
The lifecycle of a preview branch
Section titled “The lifecycle of a preview branch”A preview branch is born, used, and destroyed on the rhythm of the PR:
-
PR opened. Neon creates a branch off
main, seeded with production’s current snapshot. The preview deploys and wires itsDATABASE_URLto that branch. -
Push to the PR. The same branch is reused. Each new preview build re-runs the migrations, so the branch’s schema tracks the latest commit. You’re always testing the newest code against a database shaped for it.
-
PR merged or closed. The branch is deleted automatically. This is configurable, but the default is right: a closed PR’s database has no reason to exist.
So never store anything in a preview branch you need to keep. It is a real, throwaway copy of production you can break freely, precisely because deleting it costs nothing.
The auto-delete has a sharp edge. Closing a PR destroys the branch and every row your testing put in it. So if a preview misbehaves and you want to inspect its data to understand why, do it before you close the PR. To keep that data for debugging, copy it out or fork a new branch from it first, which is what the CLI in the next section is for.
Put the life of a per-PR preview branch in order, from opening the PR to cleanup. Drag the items into the correct order, then press Check.
main main, seeded with production’s snapshot neonctl for branches outside the preview flow
Section titled “neonctl for branches outside the preview flow”The integration owns preview branches and handles them automatically.
For any branch work outside that flow, reach for Neon’s CLI, neonctl.
You’ll need it rarely.
Three realistic cases come up: inspecting or forking a closed preview’s data before auto-delete removes it; resetting a drifted dev branch back to a fresh copy of its parent without recreating it; and forking a throwaway branch for an experiment unrelated to any PR.
pnpm add -g neonctlneonctl auth
neonctl branches listneonctl branches create --parent mainneonctl branches reset <branch>branches list shows everything that exists, which is how you catch strays.
branches reset <branch> rolls a branch back to its parent’s latest data, a clean copy without recreating the branch.
Neon also ships a branch-aware local dev loop, neonctl link, checkout, and env pull, which points local development at a specific branch, such as the dev branch in the next section.
Three environments, three branches
Section titled “Three environments, three branches”Three environments, each backed by its own Neon branch off the same Neon project:
- Development is a
devbranch you control locally, synced down withvercel env pull. - Preview is one auto-managed branch per PR, created and destroyed for you by the integration.
- Production is the
mainbranch.
The schema is the same across all three; the data is completely isolated. The opening figure crammed every environment onto one database, arrows converging on a single cylinder tinted with danger. This is that shape inverted: the arrows fan out, each environment to its own branch, and the danger is gone.
How vercel env pull scopes and pulls down that dev branch’s DATABASE_URL is the next lesson’s job.
Protecting preview data and branch limits
Section titled “Protecting preview data and branch limits”A preview is a real, breakable copy of production. That is what makes it useful, and it brings two leak surfaces you have to handle.
The first: preview URLs are publicly reachable, and the branch behind one holds a copy of production’s data, including its PII . An unprotected preview link is therefore a public window onto real customer data. Gate it behind a login. Settings → Deployment Protection → Vercel Authentication forces anyone hitting a preview URL to sign in with a Vercel account that has access to the project, so only your team sees the data. It is the recommended method and free on every plan. Turn it on before you share the first external preview link.
To share a preview with someone outside the team, such as a stakeholder without a Vercel account, use Password Protection: one password for the whole project, in the same Deployment Protection settings. Standalone Password Protection is not part of the base Pro plan; it ships with the paid Advanced Deployment Protection add-on. So reach for Vercel Authentication by default, and Password Protection only when an account-less external reviewer needs in.
For most products, gating previews and accepting the cloned-production shape is the right call. In a genuinely sensitive domain such as health or finance, you can escalate: Neon can create a schema-only branch (in Beta as of mid-2026) that copies the structure without copying a single row.
neonctl branches create --schema-onlyPreviews then exercise the real schema with zero PII to leak. The tradeoff is that you lose realistic data to click through, so choose by how sensitive your data is.
The second surface is operational: branch buildup.
Neon projects cap how many branches can exist at once (typically 100 or more), per plan.
Closed PRs auto-delete their branches, so normal flow takes care of itself; automated dependency PRs from Dependabot or Renovate open many branches but merge or close quickly.
The problem is abandoned PRs left open for weeks, whose branches sit there counting against the cap.
Trust the auto-delete, and occasionally prune strays: run neonctl branches list to spot dead branches and delete them.
The last checklist item proves the whole lesson worked. Do a test insert on a preview, then query production and confirm it is not there.
DATABASE_URL shows as a managed (locked) variable.db:migrate before the framework build.External resources
Section titled “External resources”Neon's canonical reference on copy-on-write branches, point-in-time restore, and using branches as environments.
The exact Neon-Managed Vercel integration this lesson wires, with the per-deployment environment variable details.
A deeper look under the hood, with an interactive demo that copies a 1 TB dataset in seconds.