A Neon branch per preview
Use Neon's copy-on-write branching and the Vercel-Neon integration to give every pull request preview its own isolated, production-shaped database.
There’s a gap in your setup right now, and it’s been there since the day previews started working. Every PR you open gets its own preview URL, which you saw earlier in this chapter when the repo first went live on Vercel. The problem is what that preview talks to: it reads the same DATABASE_URL as production. That’s the same connection string, pointed at the same Postgres, holding the same rows your real customers are sitting in.
This isn’t a theoretical risk. Consider what can go wrong. A teammate clicks through a preview to test a feature and signs up a fake account, and that account is now in production. A reviewer opens a PR whose whole point is a new column; the preview boots, and either it tries to add that column to the production database or the app throws because the live schema doesn’t match the code. Worst of all, a PR includes a cleanup script that wipes test data, it runs against the preview’s DATABASE_URL, and real data is gone with no way to undo it.
When previews first appeared earlier in this chapter, the advice was blunt: don’t run anything destructive on a preview until you’ve fixed this. This is where you fix it. By the end of this lesson, every PR will get its own private copy of the production database, shaped exactly like production but isolated completely from it, created automatically when the PR opens and torn down when it closes. After the one-time wiring, you won’t have to think about it again.
The goal in a single sentence: every PR should exercise a database shaped like production but that isn’t production.
Previews are running against production right now
Section titled “Previews are running against production right now”Before reaching for the fix, look closely at the problem, because the shape of the danger is what justifies the specific solution.
Picture the three kinds of thing that share one database today. There’s production, serving your customers. And there’s every open PR’s preview, each one a fully functional copy of the app, except the copy reads and writes the exact same rows production does. The following diagram makes that literal. Notice that every box points at the same cylinder.
It’s tempting to reach for a fix you already half-know, so let’s rule out the two obvious ones first, because seeing why they fail points the way to the real answer.
The first is a single shared staging database, one extra Postgres that all previews point at instead of production. That solves the production-pollution half of the problem: customers are safe. But it does nothing for the other half. 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 step on each other’s data. You’ve moved the collision, not removed it.
The second is spinning up a genuinely fresh Postgres for each PR from a CI script. That gives real isolation, but at a price that makes it impractical. Provisioning a new database and copying production’s data into it takes minutes and real money, every PR, every time. On a busy repo with a dozen open PRs, you’re paying to keep a dozen full copies of your data alive, recreated on every push.
So the requirement sharpens into three words. You need per-PR databases that are instant to create, near-free to keep around, and fully isolated. Hit all three and a database-per-PR stops being a luxury and becomes the obvious default; miss any one and you’re back to a compromise. That trio is the subject of the next section, and it’s the reason this course put your database on Neon in the first place.
How Neon branching works
Section titled “How Neon branching works”This section holds the one genuinely new idea in the lesson; everything after it is wiring. You already know Postgres, you already know Drizzle, and you already know how deploys work. What you haven’t met yet is Neon’s copy-on-write branching. Once this model clicks, the rest of the lesson falls into place. Without it, you’ll be following steps without knowing why they work.
The key move Neon makes is separating storage from compute. Your data lives in one storage layer, and the database engine that reads and writes it is a separate thing on top. Once those two are decoupled, something becomes possible that a normal Postgres can’t do: you can make a second database that points at the same stored data without copying any of it.
That pointer is called a branch , and the way it behaves is best understood in four stages. Build the mental model one stage at a time.
Stage one: a branch is a pointer at a snapshot. When you create a branch off your production database, Neon does not copy your rows. It records a snapshot , a marker that says “this new branch starts from the parent’s data as it looked right now,” and that’s it. Creating the branch is instant whether your database holds ten rows or ten million, because nothing is being duplicated. It’s a pointer, not a copy.
Stage two: reads serve from the parent. The instant after you create the branch, you can query it and get all of production’s data back. How, if nothing was copied? Because every read falls through to the shared parent storage. The branch holds no data of its own yet; it’s a thin layer that says “for anything I don’t have, ask the parent.” An untouched branch is essentially free to keep around, because it holds almost nothing.
Stage three: the first write diverges. Now you write a row on the branch. This is the copy-on-write moment, and the “only what changes” part is the whole trick. 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 shared parent. From this point the branch is fully isolated: that write exists only on the branch, the parent never saw it, and the parent could change underneath without the branch noticing. You get complete isolation while having copied only the bytes that actually differ.
Stage four: main is just the production branch. This is the piece that ties it to your app. Neon calls your primary database the main branch, and production runs on it. Every preview branches off main. So a preview doesn’t start empty, and it doesn’t start with fake fixtures. It starts as an exact snapshot of production’s data, then lives its own life from there, diverging only as the PR’s code touches it.
Scrub through those four stages in the diagram below. Watch the branch go from a bare pointer to a fully isolated copy, and watch how little actually gets duplicated along the way.
copied →
Now collect the payoff, because it justifies everything that follows. A Neon branch is instant to create, since nothing is copied; near-free to keep idle, since it holds almost nothing until it’s written to; and fully isolated the moment it diverges. That’s the exact trio the previous section said you needed: instant, near-free, isolated. No other Postgres host hands it to you as a built-in feature in 2026. This is why a database-per-PR is suddenly practical, and it’s why this course’s Postgres lives on Neon. Hold onto that, because you’ll see the alternatives at the very end and understand immediately why they’re worse.
Wiring the Native Vercel-Neon Integration
Section titled “Wiring the Native Vercel-Neon Integration”With the model in hand, the wiring is short. It’s a one-time setup that makes the per-PR branch automatic and invisible from then on. You do this once and never touch it again.
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 wire it through the Vercel dashboard.
-
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.
That’s the entire setup. From here, the per-PR branching is automatic.
When that finishes, one concrete thing changes in your project, and it’s worth seeing exactly what. The integration adds a DATABASE_URL to your Preview environment and marks it as a managed variable . You’ll see a small lock icon next to it in the dashboard, and Vercel won’t let you edit its value. That lock is intentional: the value is supposed to change on every preview deployment, because each preview gets a different branch. Hand-editing it would fight the whole mechanism, so leave it locked.
The integration leaves two other variables untouched. Your production DATABASE_URL stays exactly as you set it, pointed at main, and your local development database is untouched too. The integration owns one variable in one environment, the Preview DATABASE_URL, and nothing else.
So what actually happens on each PR now? Here’s the behavior, separate from the install steps:
- When a preview deploys, Neon creates a branch off
main, named after the git branch, something likepreview/add-export-button. - Vercel injects that branch’s connection string into that specific deployment’s
DATABASE_URL. The scope is per-deployment: the branch for PR #41’s latest commit is wired only into PR #41’s latest preview. - When the PR merges or closes, the branch is deleted automatically.
One decision came up during install that’s worth a word, because you may have been asked to pick between Neon-Managed and Vercel-Managed. For the preview-branching feature they’re functionally identical. Vercel-Managed means Neon is provisioned and billed through Vercel, which is convenient if you’re starting completely fresh. Neon-Managed means Neon stays the system of record and Vercel just pulls connection strings from it, with billing staying on Neon. This course uses Neon-Managed, for continuity rather than preference: you already set Neon up directly when you built the data layer, so keeping Neon as the single source of truth means you don’t fork your billing or your dashboards. Pick the one that matches where your database already lives.
Making the preview schema match the PR
Section titled “Making the preview schema match the PR”There’s a subtle gap the integration alone doesn’t close, and it’s easy to get wrong, so walk into it carefully.
A preview branch starts as a copy of production, and that means it copies production’s schema, not just its rows. Now think about what a PR usually contains. Often the whole point of a PR is a schema change: a new column, a new table, an index. The PR’s code expects that new shape, but the branch was forked from production, which doesn’t have the new shape yet. So the app boots against a database that’s one step behind its own code, and you get runtime errors the instant something 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’s just the framework’s build. You extend it to run your migrations first. The contrast is the teaching point, so look at the before and after.
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 itself is small. It reads DATABASE_URL from the environment, which is the key to the whole thing: it means the script automatically targets whatever branch this deployment was handed, with no per-environment configuration. It applies the migration files you already have 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' });Two details in that three-line script earn a word. 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, which is exactly the wrong tool for an ordered, stateful job. That dbUnpooled export has been in your project since the data layer, waiting for precisely this moment. And db:migrate, the script the Build Command calls, is just the package.json entry that runs this file. Nothing more than that hangs off it.
One detail is worth underlining, because it’s a discipline rather than a convenience: the build only ever applies migrations that already exist. You authored and reviewed those migration files earlier. The flow is to 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 it never pushes schema changes straight to a database without a reviewed file. The migration files in drizzle/ are the contract, and the build is just the thing that runs them.
Now the watch-out that an experienced engineer flags immediately, and the one place this pattern must not be oversold. 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. Under the careful expand-then-contract cadence of the next chapter, a production migration in the build is safe by construction. But 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 here’s the senior call, stated plainly: 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. For now, just don’t read “migrations in the build command” as a blanket pattern that’s equally fine everywhere.
The lifecycle of a preview branch
Section titled “The lifecycle of a preview branch”You’ve seen the pieces. Now watch one branch live its entire life, because a preview branch is ephemeral by design, and that property is both what makes it useful and the source of the one mistake people make with it.
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 the default, and it’s configurable, but the default is the right one: a closed PR’s database has no reason to exist.
The takeaway is worth stating bluntly: a preview branch is ephemeral by design, so never store anything in it you need to keep. It exists to be broken freely and then thrown away. That’s the feature. A preview is a real, throwaway copy of production you can do anything to, precisely because deleting it costs nothing.
That auto-delete has a sharp edge, though, worth naming before it catches you out. When you close a PR, its branch and all the data your testing put in it vanish. If a preview is misbehaving and you want to inspect its data to understand why, do it before you close the PR, because once it’s closed the evidence is gone. If you need to keep that data around to debug, copy it out or fork a new branch from it first, which is exactly what the CLI in the next section is for.
You’ve now seen every stage of the per-PR flow scattered across a few sections. Put them in order to make sure the sequence is solid in your head.
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 the branches the integration doesn’t manage
Section titled “neonctl for the branches the integration doesn’t manage”Almost everything you’ve seen happens automatically, which is the point of the integration. So where does a command-line tool fit? Exactly where the automation stops. The integration owns preview branches. For any branch work outside that flow, Neon’s CLI, neonctl, is the tool. You’ll reach for it rarely, and that’s the right expectation: if you find yourself running it daily, something is off.
There are three realistic moments to reach for it. First, the one from the last section: inspecting or forking a closed preview’s data before auto-delete takes it. Second, resetting a branch back to a fresh copy of its parent without recreating it, which is handy when your dev branch has drifted and you want production’s current shape back. Third, forking a throwaway branch for an ad-hoc experiment that has nothing to do with a PR.
Here are the commands worth knowing. Treat this as a reference you glance at, not something to memorize.
pnpm add -g neonctlneonctl auth
neonctl branches listneonctl branches create --parent mainneonctl branches reset <branch>Reading down: auth logs you in, once. branches list shows everything that exists right now, which is how you catch strays. branches create --parent main forks a fresh branch off production for an experiment. And branches reset <branch> is the clean-copy move: it rolls a branch back to its parent’s latest data, giving you a fresh start without the bother of recreating the branch. Today it only resets from the parent, which is the case you actually want.
One more thing is worth knowing about, mentioned once so you know it exists. Neon recently shipped a branch-aware local development loop: neonctl link ties your project to a Neon branch, neonctl checkout switches the active one, and neonctl env pull writes the active branch’s connection string into your local environment. It drops a small .neon file that pins which branch you’re on. This is the modern way to point local development at a specific Neon branch, and it pairs naturally with the dev branch you’ll meet in the next section. You don’t need to learn it as a workflow today; just know that’s the shape of it when you want it.
The three environments, three branches picture
Section titled “The three environments, three branches picture”Step back and look at the whole arrangement, because the mental model you should walk away with is clean and symmetric. You have three environments, and each one is backed by its own Neon branch off the same Neon project:
- Development is a
devbranch you control locally. You sync its connection string down withvercel env pull, the same command you met when you first set up the project. - 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, and the data is completely isolated. Compare this to where the lesson started. The opening figure showed every environment crammed onto one database, arrows all converging on a single cylinder, tinted with danger. This is the same shape inverted: the arrows fan out, each environment to its own branch, and the danger is gone. The fix is the mirror image of the problem.
How that dev branch’s DATABASE_URL actually gets scoped and pulled down, the mechanics of vercel env pull and per-environment variables, is the next lesson’s job. Here it’s just named as the third member of the trio, so the picture is complete.
Keeping previews safe to share
Section titled “Keeping previews safe to share”A preview is a real, breakable copy of production. That’s exactly what makes it useful, and also what makes it something you have to handle with care. Two real leak surfaces come with the territory. Treat them as a gate, not a list of nice-to-haves.
The first is that preview URLs are publicly reachable, and the branch behind one carries a copy of production’s data, which means it carries production’s PII . Put those two facts together and an unprotected preview link is a public window onto real customer data. The fix is to gate the preview behind a login. In Vercel, Settings → Deployment Protection → Vercel Authentication does exactly this: it forces anyone hitting a preview URL to sign in with a Vercel account that has access to the project, so only your team can see the data. It’s the recommended protection method, and it’s included on every plan, free on Pro with no add-on. Turn it on before you share the first external preview link.
If you need to share a preview with someone outside the team, such as a stakeholder without a Vercel account, Password Protection is the alternative: one password for the whole project, in the same Deployment Protection settings. The catch is the tier. Standalone Password Protection isn’t part of the base Pro plan; it ships with the paid Advanced Deployment Protection add-on. So Vercel Authentication is the default choice for the leak you care about here, and Password Protection is what you add when an external, account-less reviewer needs in.
For most products, gating previews behind Vercel Authentication and accepting the cloned-production shape is the right call. But if you’re in a genuinely sensitive domain such as health or finance, there’s a stronger move worth knowing as an escalation, not a default. Neon can create a schema-only branch, a first-class feature (in Beta as of mid-2026) that copies the structure without copying a single row. Your previews then exercise the real schema with zero PII in them, because there’s no production data to leak.
neonctl branches create --schema-onlyThat’s the whole trade-off, and which side of it you want depends on the data. Previews exercise the real schema with zero real rows, which is exactly the point when the data is sensitive and a downside when your testing wants realistic data to click through.
The second leak surface is operational rather than a data leak: branch buildup. Neon projects have a per-plan cap on how many branches can exist at once (typically 100 or more). Closed PRs auto-delete their branches, so in normal flow this takes care of itself. The trouble is abandoned PRs, the ones left open for weeks, whose branches just sit there counting against the cap. Automated dependency PRs from tools like Dependabot or Renovate open a lot of branches, but they’re usually fine because they merge or close quickly. The fix is mostly to trust the auto-delete-on-close, and occasionally to prune stragglers: run neonctl branches list to spot them and delete the dead ones.
Now make the whole guarantee verifiable. The following checklist is the one to tick while wiring your own project. The last item is what proves the entire lesson worked: do a test insert on a preview, then query production and confirm it isn’t there. That absence is the isolation, made concrete.
DATABASE_URL shows as a managed (locked) variable.db:migrate before the framework build.When you’re not on Neon
Section titled “When you’re not on Neon”One closing decision, named once so you know the boundary of the recommendation. Everything in this lesson rests on copy-on-write branching, and that’s a Neon capability, not something a self-hosted Postgres or a managed box like Amazon RDS offers as a first-class feature. So if your database lives somewhere without branching, what are the fallbacks, and why is each one worse?
A per-PR database created by a CI script gets you isolation, but it’s the heavy, slow, full-copy approach you ruled out at the start: you pay minutes and money to clone gigabytes per PR. A shared staging database is cheaper but gives up per-PR isolation, putting you right back in the collision problem where one preview’s migration breaks another’s. And accepting the prod-shared setup, the very thing this lesson opened by closing, is only tolerable if you can guarantee every PR is strictly non-destructive, which is not a guarantee you can actually make.
None of those is good, which is the point. Neon’s branching is why it’s this course’s Postgres default: in 2026, no other managed Postgres gives you instant, near-free, isolated per-PR databases as a built-in. The feature you wired in this lesson is the reason the stack choice was made.
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.