The production-build diff for Next.js.
Compare two App Router builds, understand what changed and why, and decide what can ship.
Quickstart · Documentation · Website · Feedback
Pre-alpha: the snapshot format, CLI output, and package API can still change.
next build
npx @moumensoliman/crust diff mainCRUST / DIFF cfdcf50068722687 (main@cfdcf500) → 4a80239769ea8b93 (feature@4a802397)
10 changed · 9 regressions · 1 improvement · attribution 94%
DECISION BLOCK
`/products/[slug]` is no longer static, +8 more
CHANGES
✗ /products/[slug] no longer static · static shell 100% → 45% · +48.2 kB
lib/http.ts:3 in <ProductGallery>
→ Cache that read (`use cache`, or `fetch(…, { next: { revalidate } })`) and the route can prerender again.
✓ /account -28.0 kB
CAUSES
+48.2 kB date-fns added · package
/products/[slug], /checkout, /analytics, +6 more (9 routes)
→ Check whether `date-fns` needs to be in the client bundle - a server component or a `next/dynamic` import removes it from first load.
crust leads with the answer, not a route inventory:
- Decision:
BLOCK,REVIEW,CLEAR, orCANNOT DECIDE - Changes: regressions and improvements, with the strongest source location and likely action
- Causes: packages, client boundaries, barrels, and call sites grouped once with their blast radius
- Coverage: how much of the client JavaScript was attributed, shown beside the verdict
This is not a report about one bundle. It is a comparison of two production-build snapshots. With two refs, both sides come from the store and neither build needs to be checked out or rebuilt:
crust diff v1.2.0 release/nextNo snapshots yet? --build records both tips first, each in its own detached git worktree, without
moving your checkout - so it works from a dirty working tree on a third branch:
crust diff develop feature --build- A route moved from static to ISR, partial, or dynamic.
- A cached read became uncached.
- A component left the static shell.
- First-load JavaScript grew, including the module or package responsible.
- A
'use client'boundary pulled a larger subtree into the browser. - A barrel import dragged additional modules onto one or many routes.
- Build or route configuration changed in a way that explains the movement.
- A refactor worked: improvements are shown alongside regressions.
One cause is reported once. If a provider, package, barrel, or call site affects nine routes, crust shows one decision with nine-route blast radius instead of nine copies of the same finding.
- Next.js 15 or 16
- App Router
- webpack or Turbopack
- Node.js 20+
- A completed production build
Hybrid projects are supported, but Pages Router routes are not measured. Outside the supported Next.js range, crust refuses to run instead of interpreting unverified build artifacts.
npm install --save-dev @moumensoliman/crust
# or
pnpm add --save-dev @moumensoliman/crustThe npm package is @moumensoliman/crust; the installed executable is crust. The unscoped
crust package is unrelated.
next build
npx @moumensoliman/crust initinit finds the app, records the first snapshot, writes explained starter budgets, and generates CI
configuration pinned to the crust version that created it. It does not replace files without
--force; use --dry-run to inspect the plan first.
In a monorepo, point it at the app:
npx @moumensoliman/crust init --cwd apps/webOr use the commands directly:
# Explain the current production build and save its snapshot
npx @moumensoliman/crust analyze
# Compare the current .next build with main at its merge base
npx @moumensoliman/crust diff main
# Compare two snapshots already in the store
npx @moumensoliman/crust diff v1.2.0 release/next
# Build both tips in temporary worktrees, then compare them
npx @moumensoliman/crust diff develop feature --buildanalyze --routes adds the complete route inventory. analyze --verbose expands coverage and
unresolved evidence. analyze --report writes the HTML report.
crust ci applies the same comparison contract as crust diff, writes an updating PR comment, and
exits non-zero on blocking findings:
npx @moumensoliman/crust ci main --comment comment.mdWithout a budget file, a comparable baseline is enough to block:
- a rendering-mode downgrade
- a newly uncached read
- disappearance of a previously emitted static shell
Project-specific limits stay explicit in .perf/budgets.json:
{
"defaultFirstLoadBytes": 250000,
"firstLoadBytes": {
"/products/[slug]": 180000
},
"maxGrowth": 0.05,
"defaultMinShellRatio": 0.6,
"allowRegression": ["/admin/[...slug]"]
}Absolute ceilings still run when no comparable baseline exists because they describe the current
build. A newly added route is not treated as a regression, and unknown evidence is never assigned
a direction.
.github/workflows/crust.yml:
name: crust
on:
pull_request:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
crust:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx next build
- uses: moumen-soliman/crust/action@master
with:
baseline: main
crust-version: 0.2.0The action fetches history, runs the check, publishes the current snapshot, and updates one PR comment. Run it on the base branch as well as pull requests so future branches have a merge-base snapshot.
See the CI guide for permissions, monorepos, fork behavior, and the complete workflow.
Every blocking CI breach is appended to .perf/findings.jsonl. Mark whether authors agreed that it
was a real regression:
crust findings list --open
crust findings agree <id>
crust findings dispute <id> --note "why"
crust findings rateThe reported rate is disputed / (agreed + disputed). Open findings do not enter the denominator,
and an empty log remains unmeasured rather than appearing as 0%.
crust joins two kinds of evidence:
- Production artifacts provide the what: route and prerender manifests, client-reference and build manifests, resolved Next.js configuration, emitted HTML, JavaScript chunks, and source maps.
- Application source provides the why: pages, layouts, rendered components, imports and exports,
Suspense and client boundaries, route configuration, dynamic APIs, fetch caching, and
'use cache'.
It follows reachable functions and imported bindings from a route to the dynamic read or attributed
module. Direct relationships can be narrowed per function. Computed calls, unresolved aliases,
namespace imports, and other ambiguous paths remain conservative or unknown.
Every conclusion is labeled verified, inferred, or unknown. crust does not turn an incomplete chain into confident blame.
Route deltas are enforced only when snapshots have compatible:
- snapshot schemas
- bundlers
- Next.js major versions
If the builds cannot be compared safely, crust explains why and withholds enforceable deltas. Configured absolute ceilings can still block because they require only the current build.
Routes are matched by their page-file identity rather than URL pattern, so URL refactors retain
history. .perf/aliases.json can stitch history across page-file moves.
Route totals, rendering modes, caching, and shell analysis work without browser source maps. Tracing client bytes to files, packages, client boundaries, and barrels requires:
// next.config.ts
export default {
productionBrowserSourceMaps: true,
}Without maps, crust keeps the route totals, reports attribution as unavailable, and does not invent a cause. Use a dedicated analysis build if you do not want to deploy browser source maps.
application source + production .next build
│
▼
route, shell, cache, and byte analysis
│
▼
comparable snapshot
│
base snapshot ─── head snapshot
│
▼
decision + changes + grouped causes
│
┌────────┴────────┐
▼ ▼
terminal PR comment
Snapshots live in .perf/ as one file per build and can synchronize through an orphan
perf-history branch. Git state, lockfile, Next.js version, Node major, bundler, and resolved config
participate in build identity so unrelated environments are not merged into one trend.
The two snapshots a comparison needs usually arrive over time - each analyze or ci run records
one. crust diff <base> <head> --build fills both at once instead: it builds each tip in a detached
git worktree, writes the snapshots into that same .perf/, and skips any tip already measured. A
build or analysis that fails names the ref it failed on rather than comparing one side against an
older snapshot.
npx @moumensoliman/crust report --openThe report is one self-contained file with no service or external requests. It opens with Fix first — the ranked findings, each with its evidence and a concrete next step — then shared-cause blast radius, then the route table. Search routes, components, and source files; filter and group routes; and expand any route to its complete cause chains.
Try it with no agent and no setup — crust ask runs the same tools and prints the answer:
crust ask # the eight tools and their arguments
crust ask build_findings # what is worth an afternoon
crust ask explain_route_cause route=/dashboardThen wire it to the agent you already use:
claude mcp add crust -- npx @moumensoliman/crust mcpcrust mcp serves the snapshots in .perf/ to any MCP-capable agent over stdio, so the coding
agent you already run can answer “why did /dashboard stop being static”, “which routes does
date-fns reach”, and “what changed between release and this branch” from recorded evidence.
crust ships no model, no embeddings and no API key. Retrieval over a snapshot is a query, not a
vector search: the store is structured, so every answer is an exact lookup that names the buildId
it came from and can be re-derived by hand with crust diff. Generation is whatever agent you
already pay for.
Eight read-only tools: list_builds, build_summary, route_detail, explain_route_cause,
compare_builds, cause_blast_radius, route_history, build_findings.
No tool builds, installs, or writes a snapshot — an agent cannot publish a baseline or start a production build. Every answer carries its attribution coverage, so a short finding list under weak coverage reads as not measured rather than nothing wrong, and a missing conclusion is returned as an explicit unknown with a reason instead of an absent field.
Next.js bundle analyzers are the better tools for exploring the client and server module graph of one build. They answer “what is in this bundle?”
crust complements them by recording compatible production builds and answering “what changed since the baseline, why did it change, everything affected, and should this merge?” It compares rendering modes, cache decisions, static-shell composition, client JavaScript, and configuration—not only bundle contents.
crust init— guided setup, first snapshot, starter budgets, and CI configurationcrust analyze— explain the current build and save a snapshotcrust diff [base] [head]— compare the current build or any two stored refs;--buildrecords both refs first, in temporary worktreescrust ci [ref]— enforce regressions and budgets; optionally write a PR commentcrust findings list|agree|dispute|rate— review and score blocking findingscrust report— generate a self-contained HTML reportcrust mcp— serve stored snapshots to an MCP-capable agent over stdio, read-onlycrust ask [tool]— run one of those tools here and print its answer; no agent neededcrust history fetch|push— synchronize snapshots withperf-historycrust list— list saved snapshotscrust prune— apply snapshot retention
Every build command accepts --cwd <dir> for monorepos and --dist-dir <dir> for a custom Next.js
output directory. See the CLI reference for all
flags and secondary commands.
The merge decision needs only source code and a production build. The in-app panel, Web Vitals and streaming collector, authenticated staging ingest, synthetic runner, and OpenTelemetry span aggregator are optional and deliberately secondary:
- Not a runtime APM: no error tracking, distributed tracing platform, or alerting service.
- Not a composite score: every result stays tied to measurable build or runtime evidence.
- Not multi-framework: Next.js App Router only; Pages Router is detect-and-warn.
- Not a hosted service: no account or SaaS dashboard.
- Not a bundler: crust reads build output and never changes how the application is bundled.
Next.js 15 and 16, webpack and Turbopack, on Node.js 20 or newer. Outside that range crust refuses to run rather than emit numbers it cannot support.
pnpm install
pnpm build
pnpm typecheck
pnpm testThe real-build tests use the apps under fixtures/; build output is intentionally not committed.
The Phase 0 webpack and Turbopack attribution results are in
docs/phase-0-findings.md.
MIT © Moumen Soliman.