You open a pull request, Vercel builds a preview, and the preview talks to the same database as production. Everyone knows this is bad and everyone does it anyway, because the alternative (a second database that someone has to keep migrated and seeded) is worse.
The symptoms arrive in a predictable order. First a migration on a preview branch adds a column that production's code does not know about, which is harmless. Then one drops a column, and production starts throwing. Then a reviewer clicks around a preview of a "delete account" feature and deletes a real account. Somewhere in between, a preview of a branch whose migration has not run yet queries a table that does not exist yet, and the reviewer reports a bug that is not real.
The wrong fixes
A single shared staging database. It drifts. Whichever preview branch migrated last owns the schema, so every other open pull request is broken. Reviewers learn to ignore preview errors, which defeats the point of previews.
Seeding a fresh empty database per preview. Correct in principle, impractical in practice: your seed script is a fiction, so the preview never reproduces the data shape that causes real bugs, the account with 40,000 rows, the row with a null in the column you assumed was populated.
Pointing previews at production read-only. Half the features under review are writes.
The right fix: one branch per preview
A Neon branch is a copy-on-write clone of another branch. It shares its parent's storage pages until it writes; only the differences cost anything. A branch of a 20 GB database that inserts a hundred rows costs the storage of a hundred rows. Creation takes seconds because nothing is copied.
That changes the economics. A per-preview database stops being an infrastructure project and becomes a line in a deploy script.
Option 1: the Vercel integration
Install the Neon integration from the Vercel marketplace and connect it to your
project. From then on, every preview deployment gets a branch created from
main, and the deployment's environment gets DATABASE_URL and
DATABASE_URL_UNPOOLED pointing at it. Merging or closing the pull request
deletes the branch.
For most teams this is the whole answer, and the rest of this page is background.
Option 2: own the script
You want this when your branch naming, retention or parent branch needs to differ from the integration's defaults.
#!/usr/bin/env bash
# scripts/preview-branch.sh: create a Neon branch for the current git branch
# and print both connection strings.
set -euo pipefail
git_ref="$(git rev-parse --abbrev-ref HEAD)"
branch="preview/${git_ref}"
# Idempotent: re-running on the same git branch reuses the branch it made.
if ! neonctl branches get "$branch" >/dev/null 2>&1; then
neonctl branches create --name "$branch" --parent main >/dev/null
fi
pooled="$(neonctl connection-string --branch "$branch" --pooled)"
direct="$(neonctl connection-string --branch "$branch")"
echo "DATABASE_URL=$pooled"
echo "DATABASE_URL_UNPOOLED=$direct"
Feed those into the deployment's environment (vercel env add ... preview, or
your host's equivalent), then apply migrations against the direct string as
part of the build. And add the other half:
neonctl branches delete "preview/${git_ref}"
on merge or close. Skip this and you will find two hundred stale branches in six months.
Migrations on a preview branch
The branch inherits the parent's schema, so a preview only needs the migrations your pull request adds. Run them on the unpooled endpoint:
DATABASE_URL="$DATABASE_URL_UNPOOLED" <your migrate command> && <your build command>
Because the branch is copy-on-write, this is a rehearsal against production-sized
data: the NOT NULL that fails on existing rows fails here, on a throwaway
database, in front of the person who wrote it. That is the single biggest
practical win of the whole arrangement, bigger than the isolation.
Things that bite
Personal data is still personal data. A branch of production contains
production rows. Preview URLs are often less protected than production, and a
branch is subject to the same GDPR/CCPA obligations as its parent. If your
production data is sensitive, branch from a sanitised parent branch: keep a
staging branch you periodically reset from main and scrub, and parent your
previews off that instead.
Branch limits and storage. Every plan caps branches. Copy-on-write storage
is cheap but not zero, and a preview that runs a big backfill writes real pages.
Delete on merge, and check neonctl branches list monthly.
Cold starts. A preview branch nobody has touched for five minutes has scaled its compute to zero and takes roughly half a second to wake. Reviewers will report the first click as slow. It is not a bug: see the cookbook page on cold starts if you want to keep specific branches warm.
The pooled/unpooled pair travels together. A preview that inherits only
DATABASE_URL will fail its first migration with a lock error that does not
mention the missing variable. Set both, every time.
Do not branch from a branch, by default. Nested branches are supported and
are occasionally exactly right, but a preview parented on another preview
inherits that preview's half-finished migrations. Parent from main unless you
mean otherwise.
What good looks like
- Opening a pull request produces a preview URL with its own database.
- The build applies pending migrations to that database on the direct URL.
- The preview's data resembles production because it started as production.
- Merging deletes the branch.
- Nobody has ever had to ask "is it safe to click delete on the preview?"