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
Behavior
- The header states direction (
fromtoto) and totals across both tabs, so the summary never depends on which tab is open. Schemalists 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
integertonumeric(12,2)marks the exact text that moved instead of tinting the whole value. Datagroups 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
hasMoreand handleonLoadMore, 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:
getProjectBranchSchemaComparisonreturns a unified diff string. Good for showing a patch, no use here.getProjectBranchSchemareturns the schema as JSON. Fetch it for both branches, compare the two, and you haveSchemaChange[].
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
fromBranchDiffBranch
Base branch of the comparison.
BranchDiffBranchtoBranchDiffBranch
Branch being compared against the base.
BranchDiffBranchschemaChangesSchemaChange[]
DDL-level changes.
SchemaChange[]dataDiffsTableDataDiff[]
Row-level changes grouped by table.
TableDataDiff[]tab?"schema" | "data"
Controlled active tab.
"schema" | "data"defaultTab?"schema" | "data"
Initial tab for uncontrolled usage.
"schema" | "data""schema"onTabChange?(tab: DiffTab) => void
Fires when the active tab changes.
(tab: DiffTab) => voidonLoadMore?(table: TableDataDiff) => void | Promise<void>
Requests the next page of row changes for one table.
(table: TableDataDiff) => void | Promise<void>loadingTableId?string | null
Table id currently loading more rows.
string | nullisLoading?boolean
Renders the loading state.
booleanfalseerror?Error | string | null
Renders an error state instead of the diff.
Error | string | nullAccessibility
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.