Skip to content

Preview branches per PR

Provisioning a whole Neon.Project per PR preview is slow and wasteful — every stage pays for cluster creation, runs into project-count limits, and replays every migration from scratch. The Neon-native pattern is to split the two tiers: one long-lived project owned by a staging stage, and one copy-on-write branch per ephemeral stage. Branches fork in seconds and are destroyed with the stage; the project outlives them all.

The project is a normal resource, created once by the stage that owns it:

import * as Neon from "alchemy/Neon";
const project = yield* Neon.Project("app-db", {
region: "aws-us-east-1",
});

Deploy it with bun alchemy deploy --stage staging. The state for Neon.Project("app-db") lands in the staging state file — that’s what PR stages will look up.

Ephemeral stages need to decide at deploy time whether to create the project or reference it. Pull the stage name off the Stack service:

import * as Alchemy from "alchemy";
const { stage } = yield* Alchemy.Stack;

stage is whatever was passed to bun alchemy deploy --stage <name>staging, prod, or pr-147.

Neon.Project.ref(id, { stage }) returns a typed reference to a project that another stage already deployed, instead of creating a new one:

const project = stage.startsWith("pr-")
? yield* Neon.Project.ref("app-db", { stage: "staging" })
: yield* Neon.Project("app-db", {
region: "aws-us-east-1",
});

The lookup is keyed by the logical ID ("app-db") and the stage whose state to read ("staging"); the stack is implied. Either side of the conditional produces a Neon.Project, so nothing downstream cares which path ran. If the staging stage hasn’t been deployed yet, plan fails with InvalidReferenceError — deploy staging first.

Below the project line, every stage — staging and previews alike — creates its own branch:

const branch = yield* Neon.Branch("app-branch", { project });

With no name, the branch gets a unique name generated from the app, stage, and logical ID, so pr-147 and pr-148 never collide. Branches are copy-on-write forks of the project’s default branch — they share storage until they diverge, so creation takes seconds regardless of database size, and each PR gets fully isolated data. See Branching for parents, point-in-time forks, and schema-only copies.

Point the branch at your migrations directory so its schema is converged as part of the deploy:

const branch = yield* Neon.Branch("app-branch", {
project,
migrationsDir: "./migrations",
});

Because the branch forks from an already-migrated parent, it inherits the parent’s schema and its tracking table — only migrations added after the fork are applied. See Migrations for ordering, hashing, and the tracking table.

expiresAt tells Neon to delete the branch itself at an RFC-3339 timestamp — a safety net for previews that outlive their deploy (a PR abandoned without running alchemy destroy):

const branch = yield* Neon.Branch("app-branch", {
project,
expiresAt: "2026-07-08T00:00:00Z",
});

expiresAt is mutable, so pushing the timestamp forward on each deploy updates the branch in place.

Terminal window
bun alchemy destroy --stage pr-147

Destroy deletes the stage’s resources — including its branch — but leaves the referenced project untouched: the PR stage doesn’t own app-db, so it can’t delete it. The shared project keeps serving staging and every other preview.

Everything above is runtime-agnostic — the branch exposes origin / pooledOrigin outputs ready to feed whatever connects to it (see Connections). For the full Cloudflare walkthrough — fronting the branch with Hyperdrive and binding it into a Worker — follow Branch from a shared database, which builds this exact pattern step by step, and Shared database across stages for the general Resource.ref mechanics, including cross-stack references.

Guides:

Related:

  • Branching — everything a Neon.Branch can do.
  • Connections — direct vs pooled URIs and the origin shape.

Reference: