Skip to content
Neon UI
Esc
navigateopen⌘Jpreview
On this page

BranchDiff

Compare two branches across schema changes and row-level data changes.

BranchDiff answers one question before a merge or a restore: what actually differs between two branches. Schema changes and row changes share a single header of added, modified, and removed counts, then split into focused tabs so DDL review and data review never fight for the same space.

Branch diff

mainbr-feature-billing
6Added5Modified2Removed
Filter changes
  • BeforeNot present
    Aftertable
    create table public.invoices (
      id uuid primary key default gen_random_uuid(),
      account_id uuid not null references public.accounts (id),
      amount_cents integer not null,
      created_at timestamptz not null default now()
    );
  • Beforeinteger
    Afternumeric(12,2)
    alter table public.accounts
      alter column balance type numeric(12,2);
  • Beforenull
    Afternot null
    alter table public.users
      alter column email set not null;
  • BeforeNot present
    Afterbtree (account_id, created_at desc)
    create index invoices_account_created_idx
      on public.invoices (account_id, created_at desc);
  • Beforebtree (legacy_ref)
    AfterRemoved
    drop index public.accounts_legacy_ref_idx;
  • BeforeNot present
    Aftercheck (amount_cents >= 0)

Behavior

  • The header states direction (from to to) and totals across both tabs, so the summary never depends on which tab is open.
  • Schema lists tables, columns, indexes, and constraints. Opening a change shows before and after values plus the generated DDL.
  • Changed values are diffed word by word, so integer to numeric(12,2) marks the exact text that moved instead of tinting the whole value.
  • Data groups row changes by table. Opening a row shows a per-column before and after table, with only the changed columns tinted.
  • Search and the kind filter apply to the active tab and compose with each other.
  • Breaking schema changes carry an explicit label, not just color.
  • Large tables can page: set hasMore and handle onLoadMore, which supports a promise and drives the row’s loading state.
  • Wide value tables scroll inside the component with .neon-scroll-fade; the page never gains horizontal overflow.

Install

npx shadcn@latest add https://ui.neon.com/r/branch-diff.json

Usage

import { BranchDiff } from "@/components/neon-ui/branch-diff";

<BranchDiff
  from={{ id: "br-main", name: "main" }}
  to={{ id: "br-feature-billing", name: "br-feature-billing" }}
  schemaChanges={schemaChanges}
  dataDiffs={dataDiffs}
/>;

Wiring to Neon

Neon has two schema endpoints, both on the SDK’s raw layer:

  • getProjectBranchSchemaComparison returns a unified diff string. Good for showing a patch, no use here.
  • getProjectBranchSchema returns the schema as JSON. Fetch it for both branches, compare the two, and you have SchemaChange[].
import { raw } from "@neon/sdk";
import { createNeonClient } from "@/lib/neon-client";

const neon = createNeonClient(process.env.NEON_API_KEY);

const schemaOf = async (branchId: string) => {
  const { data } = await raw.getProjectBranchSchema({
    client: neon.client,
    path: { branch_id: branchId, project_id: projectId },
    query: { db_name: "neondb" }, // required
  });

  return data?.json;
};

const [base, head] = await Promise.all([
  schemaOf(fromBranchId),
  schemaOf(toBranchId),
]);
// walk base.tables against head.tables into SchemaChange[]

The JSON has tables, columns and constraints but no indexes, so index changes need pg_indexes. breaking is yours to decide: the API has no opinion on what breaks your app.

Row changes have no endpoint. Compare the tables you care about on the server, since diffing them in the browser means shipping both branches’ rows to the client.

Props

PropType
fromBranchDiffBranch

Base branch of the comparison.

TypeBranchDiffBranch
toBranchDiffBranch

Branch being compared against the base.

TypeBranchDiffBranch
schemaChangesSchemaChange[]

DDL-level changes.

TypeSchemaChange[]
dataDiffsTableDataDiff[]

Row-level changes grouped by table.

TypeTableDataDiff[]
tab?"schema" | "data"

Controlled active tab.

Type"schema" | "data"
defaultTab?"schema" | "data"

Initial tab for uncontrolled usage.

Type"schema" | "data"
Default"schema"
onTabChange?(tab: DiffTab) => void

Fires when the active tab changes.

Type(tab: DiffTab) => void
onLoadMore?(table: TableDataDiff) => void | Promise<void>

Requests the next page of row changes for one table.

Type(table: TableDataDiff) => void | Promise<void>
loadingTableId?string | null

Table id currently loading more rows.

Typestring | null
isLoading?boolean

Renders the loading state.

Typeboolean
Defaultfalse
error?Error | string | null

Renders an error state instead of the diff.

TypeError | string | null

Accessibility

Search and the kind filter sit on their own row below the tabs, so neither control is squeezed in narrow containers. Tabs come from the shared Base UI primitive, so arrow keys move between Schema and Data. One shared pill slides between the two tabs in 200ms while the panel fades in from the direction of travel; reduced motion switches tabs instantly. Every change row is a real button with aria-expanded and aria-controls. Change kind is carried by a text label and a symbol, not color alone, and the header exposes a total count for screen readers. Load-more sets aria-busy and disables repeat presses. Disclosure height and opacity settle in 150ms; reduced motion opens rows instantly.