
Nextjs Bundle Optimizer
- 86 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
nextjs-bundle-optimizer is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
Key points
- nextjs-bundle-optimizer
- AI & Agent Building
- AI-coding skill
Nextjs Bundle Optimizer by the numbers
- 86 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,032 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill nextjs-bundle-optimizerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 86 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with ai & agent building tasks during ai-assisted development?
Helps with ai & agent building tasks during AI-assisted development.
Who is it for?
Best when you're working on ai & agent building and need structured help with nextjs-bundle-optimizer.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks during ai-assisted development, or when nextjs-bundle-optimizer is a claude code skill for ai & agent building. it helps solo builders move faster with ai-assisted coding.
What you get
Structured output aligned to nextjs-bundle-optimizer: nextjs-bundle-optimizer; AI & Agent Building; AI-coding skill.
Files
Next.js Bundle & Build-Time Optimizer
Data-driven optimization loop for Next.js 16 applications. The skill orchestrates next experimental-analyze, builds, type checks, and tests to make verifiable improvements one change at a time.
When to Apply
Use this skill when:
- The user wants to reduce bundle size / First Load JS / route bundles in a Next.js 16 app.
- Production builds are slow and the user wants to diagnose and fix the bottleneck.
- The user shares analyzer output, a screenshot of the treemap, or asks "why is
<X>so big?" - A new dependency was added and bundles grew — they want to understand why and unwind it.
- The user wants to set a performance budget and enforce it iteratively.
Why iterative (not "apply all optimizations at once")
Bundle optimization is full of foot-guns. Examples:
- Moving a "client-only" dep behind
next/dynamiccan break SSR consumers that depended on its presence at first paint. experimental.optimizePackageImportswith the wrong package can mis-resolve subpath exports.- Aggressive
modularizeImportsregex rewrites can hit unintended modules. - Narrowing the polyfill target can break older mobile browsers in production.
The only reliable way is: one change, measure, verify, commit (or revert). This skill enforces that discipline through scripts.
Prerequisites
- Next.js 16+ project (the skill auto-detects from
package.json). git(the workflow uses commits as checkpoints and revert as rollback).jqavailable on PATH (used by analyze/compare scripts).- A clean working tree before starting (the verify step uses
git statusto confirm). - For projects still on Next.js 15 or earlier in webpack mode: the skill detects this and uses
@next/bundle-analyzerinstead ofexperimental-analyze. See gotchas.md.
Setup
On first use, run:
bash scripts/baseline.sh --setupThis walks through config.json interactively. If config.json already has values, scripts use those and skip the prompts.
Workflow Overview
┌── 1. baseline ──┐ Clean build, snapshot bundles + timing → baselines/{timestamp}/
│ │
│ 2. analyze ───┤ Run `next experimental-analyze --output`, extract top offenders
│ │
│ 3. diagnose ──┤ Map each offender to a recipe in references/optimizations.md
│ │
│ ── apply ─────┤ User (or agent) applies ONE recipe. Skill helps draft the change.
│ │
│ 4. measure ───┤ Re-build, re-analyze, save to iterations/{n}/
│ │
│ 5. compare ───┤ Diff vs baseline; flag regressions; print delta table
│ │
│ 6. verify ────┤ Build + tsc + tests + no overall regression. Hard PASS/FAIL.
│ │
│ commit OR ────┘ `git commit` (PASS+improvement) or `git revert` (FAIL/no win)
│ revert
│
└── loop back to step 2 with new baseline if you want a fresh measurement point.Read references/workflow.md for the detailed loop with error handling, rollback procedures, and what each script's output looks like.
Quick Reference
Scripts (run from the Next.js app root)
| Script | Purpose | Output |
|---|---|---|
scripts/baseline.sh | Establish the reference point | baselines/{ts}/{bundle,timing,manifest}.json |
scripts/analyze.sh | Run analyzer, parse top offenders | iterations/{n}/findings.json |
scripts/diagnose.sh [findings.json] | Map findings → recipes | stdout: prioritized recipe list |
scripts/measure.sh | Re-measure after a change | iterations/{n}/{bundle,timing}.json |
scripts/compare.sh [baseline] [current] | Diff snapshots | stdout: delta table; exit 1 on regression |
scripts/verify.sh | Full verification | exit 0 = safe to commit, exit 1 = revert |
Optimization Recipes
references/optimizations.md catalogs recipes by analyzer signal:
Bundle size
- Barrel-import bloat →
experimental.optimizePackageImports(webpack) / auto in Turbopack - Heavy client lib pulled in on every route →
next/dynamic({ ssr: false }) - Server-only lib imported from a client component → move to Server Component
- Duplicate dependency versions → dedupe via
overrides/resolutions - Polyfill bloat → tighten
browserslist - Per-icon imports for icon libraries
Build time
- Enable
experimental.turbopackFileSystemCacheForBuild - Narrow
transpilePackages(overuse balloons compile time) - Split
tsc --noEmitfromnext buildin CI - Identify single-file bottlenecks with
NEXT_TURBOPACK_TRACING=1
Each recipe documents: signal → fix → expected impact → verify step.
Gotchas
See gotchas.md for accumulated failure points. Initially seeded with the most common Next.js 16 traps:
next buildno longer prints First Load JS — don't grep build output for it.optimizePackageImportsis a no-op under Turbopack (default in 16) — Turbopack auto-optimizes.- Comparing build times needs cache state controlled — either delete
.next/(cold) or pre-warm (warm).
Related Skills
vercel:nextjs— general Next.js App Router guidance; consult for refactoring decisions while applying recipes.vercel:turbopack— for build-time debugging beyond what this skill covers.vercel:next-cache-components— when a recipe touches caching boundaries.
{
"package_manager": "",
"app_dir": "",
"build_command": "",
"test_command": "",
"typecheck_command": "",
"budget_dir": ".bundle-budgets",
"_setup_instructions": {
"package_manager": "One of: npm, pnpm, yarn, bun. Detect from lockfile if unsure (pnpm-lock.yaml, yarn.lock, bun.lockb, package-lock.json).",
"app_dir": "Absolute or relative path to the Next.js app root (the dir containing next.config.{js,ts}). Use '.' if running from inside the app.",
"build_command": "Command that runs `next build`. Examples: 'npm run build', 'pnpm build', 'yarn build'. Leave blank to default to '<package_manager> run build'.",
"test_command": "Command that runs the test suite. Examples: 'npm test', 'pnpm vitest run', 'jest --ci'. Leave blank to skip test verification.",
"typecheck_command": "Command that type-checks without emitting. Default: 'npx tsc --noEmit'. Override if the project uses a different command (e.g. 'pnpm run typecheck').",
"budget_dir": "Optional. Directory (relative to the Next.js app) where per-route bundle budgets live. Default: '.bundle-budgets'. Not strictly required for the iteration loop."
}
}
Gotchas
Specific failure points encountered while running this skill. Append-only with dates.
Seeded gotchas (Next.js 16)
next build no longer prints First Load JS per route
Why it matters: Older scripts that grep build output for "First Load JS" or "Size" will silently find nothing. Fix: Use next experimental-analyze --output + the build-manifest.json sums computed by scripts/baseline.sh. Do not regress to grepping stdout. Added: 2026-05-12 (initial seed — Next.js 16 release note)
experimental.optimizePackageImports is a no-op under Turbopack
Why it matters: Turbopack (default in Next 16) auto-optimizes barrel imports. Adding packages to optimizePackageImports does nothing under Turbopack — yet the same change applied to a webpack-mode project would help. Fix: scripts/_common.sh detects the bundler. The diagnose step only suggests optimizePackageImports when bundler is webpack. If you find yourself reaching for this option under Turbopack, the issue is somewhere else. Added: 2026-05-12 (initial seed)
Comparing cold vs warm builds invalidates timing deltas
Why it matters: experimental.turbopackFileSystemCacheForBuild (Next 16 opt-in) makes second builds dramatically faster. If the baseline was cold and the measurement is warm (or vice versa), the time delta is mostly cache, not your optimization. Fix: baseline.sh always runs cold (rm -rf .next). measure.sh defaults to cold; use --warm deliberately if you want to measure warm-build savings specifically. Added: 2026-05-12 (initial seed)
Dirty working tree pollutes measurements
Why it matters: Each iteration must isolate the impact of ONE change. Uncommitted changes from previous iterations or unrelated work attribute results to the wrong cause. Fix: _common.sh::ensure_clean_git aborts before any measurement run if git status --porcelain is non-empty. Commit or stash first. Added: 2026-05-12 (initial seed)
experimental-analyze may fail in restrictive CI environments
Why it matters: The analyzer writes to .next/diagnostics/analyze/. Some sandboxes restrict writing outside the cwd or have permission quirks. Fix: baseline.sh and analyze.sh treat analyzer failure as a warning (not fatal) and fall back to manifest-only snapshots. You lose the treemap but retain per-route byte totals — enough for compare.sh to function. Added: 2026-05-12 (initial seed)
---
How to add a gotcha
When something surprises you during a run, append a new entry below:
### One-line title
**Why it matters:** 1-2 sentences on the symptom and what it cost you.
**Fix:** Concrete steps or pointer to the script that handles it.
Added: YYYY-MM-DD{
"version": "1.0.3",
"organization": "dot-skills",
"technology": "Next.js 16",
"discipline": "composition",
"type": "automation",
"date": "May 2026",
"abstract": "Data-driven optimization loop for Next.js 16 bundle size and build time. Orchestrates `next experimental-analyze`, build timing, type checks, and tests to make verifiable improvements one change at a time. Enforces baseline → analyze → diagnose → apply → measure → compare → verify discipline with hard PASS/FAIL gates so each commit is a confirmed improvement.",
"references": [
"https://nextjs.org/docs/app/guides/package-bundling",
"https://nextjs.org/docs/app/api-reference/cli/next",
"https://nextjs.org/docs/app/api-reference/config/next-config-js/turbopackFileSystemCache",
"https://nextjs.org/docs/app/api-reference/config/next-config-js/optimizePackageImports",
"https://nextjs.org/docs/app/api-reference/turbopack",
"https://nextjs.org/docs/app/guides/upgrading/version-16",
"https://nextjs.org/docs/app/guides/lazy-loading"
]
}
Optimization Recipes
A catalog of recipes addressing the most common findings from analyze.sh. Each recipe has the same shape:
- Signal — what the analyzer/diagnose output looks like
- Fix — the minimum-diff change
- Expected impact — rough magnitude based on the public benchmarks linked
- Verify — what
compare.shshould show; whatverify.shcatches
Recipe IDs match the anchors emitted by diagnose.sh (e.g. #shared-heavy-dep).
---
Bundle-size recipes
shared-heavy-dep
Signal: A single chunk >100 KB used by ≥3 routes. The treemap shows a third-party library (lodash, moment, large UI kit) at the top.
Fix — typical sequence: 1. Identify which packages contribute. In the Turbopack analyzer, click the chunk and inspect the module list. Outside the analyzer: grep -r "from 'lodash'" src/ (or whichever lib). 2. Replace barrel imports with subpath imports:
// Before — pulls all of lodash
import { debounce, throttle } from 'lodash'
// After — only the two functions
import debounce from 'lodash/debounce'
import throttle from 'lodash/throttle'3. For webpack-mode projects (Next < 16 or --no-turbo), add to next.config.js:
experimental: {
optimizePackageImports: ['lodash', '@mui/icons-material'],
}Skip this on Turbopack — it's already automatic.
4. For date libraries (moment → date-fns or dayjs), do the smallest swap first: only at one call site, verify, then propagate.
Expected impact: 30–80% reduction for the targeted chunk. Lodash full → tree-shaken commonly saves 50–60 KB gzipped.
Verify: compare.sh shows the chunk shrink. verify.sh confirms no behavioral break (often: a missing subpath, like lodash/fp not having the same default-export semantics).
Common pitfalls:
lodashhas different ESM/CJS layouts. The subpathlodash/debounceworks in both; named-import-from-package works inlodash-es.- Some libraries publish broken subpath exports — verify in dev before committing.
---
large-route-bundle
Signal: A single route's total exceeds ~250 KB (warning) or ~500 KB (critical).
Fix: 1. Open the analyzer treemap for that route. Look at the top 3 modules by area. 2. For each: is it actually needed on first paint?
- Heavy interactive widget (charts, date pickers, code editors): wrap with
next/dynamic.
'use client'
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('./Chart'), {
ssr: false,
loading: () => <ChartSkeleton />,
})- Server-only utility imported from a client component: move the import into a Server Component. The lib never ships to the browser.
- "Just in case" import (a util that's exported broadly but only used by one rare path): inline-import inside the handler.
3. For client components that fetch and render on mount, consider whether the work can move to a Server Component and stream HTML.
Expected impact: A next/dynamic({ ssr: false }) move on a chart library can shrink the route by 100–300 KB gzipped.
Verify: compare.sh shows the route drop. verify.sh runs your tests — these often catch SSR/hydration issues if you set ssr: false on something that was being rendered on the server.
Common pitfalls:
ssr: falseproduces empty HTML for that component on first paint. If LCP is the metric, you may have moved the cost from JS to the missing content. Measure with Lighthouse separately.- Don't
next/dynamiceverything — each dynamic boundary adds a network round-trip to fetch the chunk.
---
polyfill-bloat
Signal: A chunk named like polyfills*.js, core-js*, or a framework chunk with disproportionate size for the modern-browser baseline.
Fix: 1. Inspect the project's browserslist config (in package.json or .browserslistrc). 2. Drop legacy targets if your users are on modern browsers:
"browserslist": [
"defaults and supports es6-module"
]3. Audit any @babel/preset-env config — Next.js 16 with Turbopack mostly bypasses Babel, but webpack-mode projects may still hit Babel for some packages. 4. If a specific dependency forces a polyfill (e.g. it imports core-js directly), the right fix is to upgrade or replace that dependency, not the polyfill.
Expected impact: 20–50 KB reduction depending on initial baseline. Largest wins come from dropping IE-era targets.
Verify: compare.sh on the polyfill chunk. verify.sh tests on a CI runner matching your real-browser baseline (this is where you discover that Array.prototype.at isn't in iOS 14 Safari).
Common pitfalls:
- "Tighten browserslist" can silently break in production for old mobile browsers that don't show up in your local testing. Roll out behind a canary if possible.
---
heavy-client-component
Signal: A route-specific chunk >200 KB, treemap shows it's dominated by one component or feature.
Fix: Same playbook as large-route-bundle, applied to the specific component:
1. Is this a client component that doesn't actually need to be? Mark it as a Server Component (remove 'use client') if it doesn't use hooks, browser APIs, or event handlers. 2. Is the component imported eagerly but rendered conditionally? Lazy-load:
const HeavyModal = dynamic(() => import('./HeavyModal'))
// Render only when needed:
{isOpen && <HeavyModal />}3. Is the component a wrapper that re-exports a heavy lib? Often you can drop the wrapper and import the lib directly only where needed.
Expected impact: Varies widely. A next/dynamic move on a confirmed-not-needed-on-first-paint component is typically 50–150 KB.
Verify: Standard compare.sh + verify.sh. Pay special attention to hydration warnings in tests — ssr: false removes SSR HTML for the boundary.
---
duplicate-deps
Signal: Multiple versions of the same package in the analyzer (e.g. react-is@17 and react-is@18 both present), or two chunks containing what looks like the same code.
Fix: 1. Identify the culprit:
npm ls <package> # or `pnpm why <package>` / `yarn why <package>`2. Force resolution to one version via package.json:
// npm:
"overrides": { "react-is": "^18.2.0" }
// pnpm:
"pnpm": { "overrides": { "react-is": "^18.2.0" } }
// yarn:
"resolutions": { "react-is": "^18.2.0" }3. Reinstall: rm -rf node_modules && <package_manager> install 4. Re-baseline (the install step changes the entire dependency graph; the previous baseline is no longer comparable).
Expected impact: Eliminates one full copy of the duplicated package. For mid-sized libs, 10–80 KB.
Verify: compare.sh should show a global decrease. verify.sh catches behavioral breaks — overrides occasionally pin a sub-dep to an incompatible version.
Common pitfalls:
- Overrides are powerful and dangerous; a wrong major-version override can break unrelated packages. Always run the full test suite.
- Re-baselining is mandatory after a dependency tree mutation. Don't try to
compare.shacross that change.
---
icon-library
Signal: A chunk containing thousands of icons from @mui/icons-material, lucide-react, react-icons, etc.
Fix: Use per-icon imports:
// Before — naive named import (may or may not be tree-shaken depending on bundler):
import { Search, Menu, Close } from 'lucide-react'
// After — explicit per-icon paths (always tree-shaken):
import Search from 'lucide-react/dist/esm/icons/search'
import Menu from 'lucide-react/dist/esm/icons/menu'For Turbopack, the named import form is usually already optimal — verify by re-analyzing after the build.
For webpack mode, add to optimizePackageImports:
experimental: {
optimizePackageImports: ['lucide-react', '@mui/icons-material'],
}Expected impact: Often dramatic — icon libraries can be 1–2 MB; per-icon import brings it down to KBs.
Verify: Per-route delta on any route that imports icons. Visual smoke test of the affected routes.
---
framework-chunks
Signal: A framework*.js chunk in the top offenders.
Fix: Usually not worth touching directly. Framework chunks contain React, Next.js runtime, and a few core deps — they're shared across all routes and benefit from long-term caching.
If the framework chunk is genuinely outsized (>200 KB gzipped), look for:
- A heavy library that got hoisted into the shared chunk (e.g. a chart library imported by every page).
- An accidental "everything" barrel re-export from a shared
lib/index.ts.
Expected impact: Low; usually 0. Spend the iteration on a different recipe.
---
Build-time recipes
turbopack-fs-cache
Signal: Cold builds take >1 minute and you re-build often (CI or local dev).
Fix:
// next.config.ts
const nextConfig = {
experimental: {
turbopackFileSystemCacheForBuild: true, // opt-in beta in Next 16
// turbopackFileSystemCacheForDev is on by default in Next 16
},
}Expected impact: Warm builds 30–70% faster. Cold builds: no change.
Verify: Run measure.sh twice — first cold (default), then measure.sh --warm. Compare the two timing.json build_seconds.
Caveats:
- "Beta" in Next 16 — verify the build artifact in production before relying on it for releases.
- The cache lives in
.next/cache. CI needs to persist this directory between runs to benefit.
---
narrow-transpilepackages
Signal: Build is slow, and next.config.{js,ts} has a long transpilePackages list — often a list that has grown over time and includes packages that no longer need transpilation.
Fix: 1. Audit each entry. For each package, check if its dist/ output is already ESM/CJS-compatible (look at package.json "type" and "exports"). 2. Remove entries that don't need transpilation. 3. Test by building — if a removed entry was actually needed, the build fails with a clear "unexpected token" or similar error. Re-add it.
Expected impact: Each unnecessary entry adds compile cost proportional to the package size. Removing a large unnecessary entry can save 5–20% of build time.
Verify: measure.sh build-time delta. verify.sh catches the case where a removed entry was actually load-bearing.
---
typecheck-out-of-build
Signal: next build runs type-checking inline and dominates build time. Visible in build logs as "Linting and checking validity of types" taking many seconds.
Fix:
1. Disable type-checking during next build:
const nextConfig = {
typescript: { ignoreBuildErrors: false }, // keep validation, just decouple
// Or, more aggressively for CI flow control:
// typescript: { ignoreBuildErrors: true }
}2. Run tsc --noEmit as a separate CI job in parallel with next build. 3. Fail the CI pipeline if either job fails.
Expected impact: Removes type-check time from the critical path. Often 10–40 seconds on mid-size projects.
Verify: measure.sh build-time delta. verify.sh still runs the separated tsc --noEmit as a verification step, so type safety is preserved.
Caveats:
- Don't
ignoreBuildErrors: truein dev workflows — you want fast feedback locally. - This is a CI-shape change, not a code change; rolling it out means updating the pipeline.
---
tracing-bottleneck
Signal: Build time is bad and the previous recipes haven't moved it. You suspect a single file or module is the bottleneck.
Fix:
NEXT_TURBOPACK_TRACING=1 npm run buildThis produces .next-profiles/trace-turbopack. Inspect with chrome://tracing or share with the Next.js team via a GitHub issue.
For deeper analysis: search the trace for spans >500ms. Common culprits:
- A single component file with extreme generic-type complexity.
- A circular import chain that triggers re-compilation.
- A
node_modulespackage with a corrupted source map.
Expected impact: Diagnostic only — produces evidence, not a fix. The fix follows from what you find.
Verify: Compare trace before/after applying whatever fix the trace points to.
Caveats:
- The trace file is large (tens of MB on real apps). Don't commit it.
- The trace format is experimental and may change between Next.js versions.
---
Recipe selection cheat sheet
Finding (from diagnose.sh) | First recipe to try |
|---|---|
shared-heavy chunk with library name in path | #shared-heavy-dep |
shared-heavy chunk named polyfill/legacy | #polyfill-bloat |
| Route-specific chunk >500 KB | #large-route-bundle → #heavy-client-component |
| Two versions of same package in graph | #duplicate-deps |
| Icon library in top offenders | #icon-library |
| Slow CI builds, no bundle issue | #turbopack-fs-cache, #typecheck-out-of-build |
Slow build, suspicious transpilePackages | #narrow-transpilepackages |
| Slow build, no obvious cause | #tracing-bottleneck (diagnostic) |
Workflow — Detailed
The full iteration loop, with error handling and rollback. Read this when running the skill — SKILL.md is the quick reference; this is the operating manual.
Mental model
Bundle and build-time optimization is empirical. Theories about what's "slow" are usually wrong. Trust the analyzer, change one thing, measure again, keep what works.
The loop has six scripts. Each one's exit code is a checkpoint. If any step fails, you stop and revert — you don't paper over the failure and continue.
git: clean tree required
│
▼
baseline.sh ──► baselines/{ts}/
│ ↳ symlinked as baselines/current
▼
┌── analyze.sh ──► iterations/{ts}/findings.json
│ │
│ ▼
│ diagnose.sh ──► stdout: ranked recipes
│ │
│ ▼
│ APPLY ONE RECIPE
│ (manual; agent helps draft)
│ │
│ ▼
│ measure.sh ──► iterations/{ts}/{bundle,timing}.json
│ │
│ ▼
│ compare.sh ──► stdout: delta table; exit 1 on regression
│ │
│ ▼
│ verify.sh ──► build + tsc + tests + no-regression
│ │
│ ├── PASS ─► git commit ─► (loop or stop)
│ │
│ └── FAIL ─► git reset --hard HEAD ─► loop with next recipe
│
└─────────────────────────────────────────────────────────
(loop back to analyze or to a fresh baseline
if you want a new measurement point)Step-by-step
0. Prerequisites (one-time)
1. config.json populated (see _setup_instructions). 2. Working tree clean: git status shows nothing. 3. jq available: command -v jq succeeds. 4. Production build works at all: npm run build (or your build_command) succeeds at HEAD.
If any fails: fix the prerequisite. The skill cannot help while the build is broken.
1. baseline.sh — establish the reference point
What it does:
rm -rf .next(cold build for reproducibility)- Runs
$BUILD_COMMAND, captures wall-clock time - For Next.js 16 + Turbopack: runs
next experimental-analyze --output - Snapshots
.next/build-manifest.jsonand.next/app-build-manifest.json - Sums per-chunk bytes per route →
baselines/{ts}/bundle.json - Saves
timing.jsonwithbuild_secondsandcache: "cold" - Symlinks
baselines/current→ this run
Failure modes:
- Build fails →
exit 1. Fix the build at HEAD; nothing this skill does helps a broken main branch. experimental-analyzefails → continues with a warning (manifest-only snapshot still works).
When to re-baseline:
- After a successful optimization commit, if you want to start a new "session" with the post-improvement state as the reference.
- After upgrading Next.js, React, or other foundational deps.
- Don't re-baseline mid-loop — you'll lose your ability to compare against the original.
2. analyze.sh [iteration-name] — extract findings
What it does:
- Runs
next experimental-analyze --output(or reuses output if already present in.next/) - Walks
build-manifest.jsonto compute per-chunk sizes, per-route totals, cross-route usage counts - Writes
iterations/{name}/findings.jsonwith: top_chunks, heaviest_routes, hints
Reading the output:
Top chunks (largest):
234567 B static/chunks/main-abc.js (×8 routes) ← shared & heavy
123456 B static/chunks/app/page-def.js (×1 route) ← route-specific & heavy
Heaviest routes:
654321 B app:/dashboard (12 chunks) ← deep-dive this oneA heavy chunk used by many routes is usually a barrel import, polyfill, or a shared util that's gotten bloated. A heavy chunk used by one route is usually a heavy client component, wrong-side import, or a candidate for next/dynamic.
3. diagnose.sh [findings.json] — map findings to recipes
What it does:
- Classifies each top offender by heuristics (shared/route-specific, polyfill-like, framework, threshold sizes)
- Emits a prioritized list pointing at recipes in
references/optimizations.md
Reading the output:
1. [HIGH] 234 KB shared chunk used by 8 routes: static/chunks/main-abc.js
Recipe: references/optimizations.md#shared-heavy-dep
Why: A heavy dep imported across many routes multiplies the cost.
2. [MEDIUM] 312 KB route bundle: app:/dashboard
Recipe: references/optimizations.md#large-route-bundle
Why: Approaching the warning threshold; opportunistic optimization worthwhile.The discipline: pick exactly ONE finding and apply its recipe. Resist the urge to fix two at once — if you do, you can't attribute the result.
4. Apply the recipe (manual / agent-assisted)
Open references/optimizations.md, find the section matching the recipe id, and apply the change. The agent should:
1. Read the recipe's "Signal", "Fix", "Expected impact", and "Verify step". 2. Locate the offending import / config in the codebase (grep, the analyzer treemap, or import chain trace). 3. Draft the minimal diff. 4. Confirm with the user before writing (per the project's CLAUDE.md write discipline).
5. measure.sh [name] — re-measure
What it does:
rm -rf .next(cold by default; pass--warmto skip)- Re-runs
$BUILD_COMMAND - Delegates to
analyze.shto capture newbundle.jsonandfindings.json
Failure modes:
- Build fails → writes
timing.jsonwithstatus: build_failedand exits 1. The recipe broke the build. Revert and try another.
6. compare.sh — delta against baseline
What it does:
- Diffs
baselines/current/bundle.jsonvs the latest iteration'sbundle.json - Prints overall delta + top 15 per-route changes
- Prints build-time delta
- Exit code: 0 if overall bytes decreased or stayed the same; 1 if overall grew
Reading the output:
Total: 1240.5 KB → 1098.3 KB (-142.2 KB, -11.5%)
Top 15 route deltas:
↓ -78.4 KB -23.1% app:/dashboard
↓ -45.8 KB -18.2% app:/settings
↑ +2.1 KB +0.8% app:/login ← acceptable noiseA small regression on a non-target route is normal — chunk hashes shift around as dependencies regroup. The exit code is based on overall, not per-route.
7. verify.sh — hard PASS/FAIL
Runs in order: 1. Build (skipped if a fresh .next/ is present from measure.sh). 2. Type check ($TYPECHECK_COMMAND). 3. Tests ($TEST_COMMAND, skipped if no test files are found). 4. Bundle regression check (delegates to compare.sh).
Exit 0 = safe to commit. Exit 1 = revert. There is no in-between. The user-facing CLAUDE.md TDD discipline applies: you do not commit failures.
8. Commit OR revert
On PASS:
git add -A
git commit -m "optim(<recipe>): <route or chunk> -<delta>"Example commit message: optim(shared-heavy-dep): main-abc.js -78 KB (lodash → lodash-es subpath)
On FAIL:
git reset --hard HEAD # discard the experimental change
# Re-read diagnose.sh output and pick the next recipe.If you want to investigate WHY the change failed before discarding, git stash instead — but commit-or-discard is the steady state. Half-applied optimizations rot.
Rollback at any step
Every measurement script writes to iterations/{name}/ or baselines/{ts}/ — never back to the project. The Next.js project is only modified when you apply a recipe. So rollback is always:
git reset --hard HEAD # last good state
rm -rf .next # next build will be cold and cleanSkill state in baselines/ and iterations/ is fine to keep; it's historical data.
When to stop optimizing
Stop when:
diagnose.shreports "No obvious offenders" twice in a row after iterations.- The next ranked recipe targets a chunk under ~50KB. Returns diminish fast below that.
- Build-time has plateaued and bundle deltas are <1% per iteration.
Document where you stopped (gotchas.md or commit message) so the next session has a starting point.
Troubleshooting
| Symptom | Likely cause | Action |
|---|---|---|
ERROR: working tree is dirty | Uncommitted changes from previous iteration | git stash or git commit first |
experimental-analyze writes no .next/diagnostics/analyze | Project on Next 15 / webpack mode | Skill falls back to manifest-only; consider installing @next/bundle-analyzer |
compare.sh always shows huge deltas | Comparing cold vs warm builds | Both runs must match cache state; use baseline.sh (always cold) and measure.sh without --warm |
verify.sh fails on tests that always pass locally | Build mutated something unexpectedly (env, snapshots) | Inspect iterations/{name}/build.log; revert and consider whether the recipe touches test infrastructure |
| Recipe is applied but bundle didn't change | The targeted import wasn't actually the bottleneck | Re-run analyze.sh from the post-change state; treemap rarely lies |
#!/usr/bin/env bash
# _common.sh — shared helpers for nextjs-bundle-optimizer scripts.
# Sourced, not executed. Use: source "$(dirname "$0")/_common.sh"
set -euo pipefail
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONFIG_FILE="${CLAUDE_PLUGIN_DATA:-$SKILL_DIR}/config.json"
if [[ ! -f "$CONFIG_FILE" ]]; then
echo "ERROR: config.json not found at $CONFIG_FILE" >&2
echo "Run: bash scripts/baseline.sh --setup" >&2
exit 1
fi
require_jq() {
command -v jq >/dev/null 2>&1 || {
echo "ERROR: jq is required. Install: brew install jq" >&2
exit 1
}
}
cfg() {
jq -r --arg k "$1" '.[$k] // empty' "$CONFIG_FILE"
}
PKG_MANAGER="$(cfg package_manager)"
APP_DIR="$(cfg app_dir)"
BUILD_CMD="$(cfg build_command)"
TEST_CMD="$(cfg test_command)"
TYPECHECK_CMD="$(cfg typecheck_command)"
BUDGET_DIR="$(cfg budget_dir)"
PKG_MANAGER="${PKG_MANAGER:-npm}"
APP_DIR="${APP_DIR:-.}"
BUILD_CMD="${BUILD_CMD:-${PKG_MANAGER} run build}"
TEST_CMD="${TEST_CMD:-${PKG_MANAGER} test}"
TYPECHECK_CMD="${TYPECHECK_CMD:-npx tsc --noEmit}"
BUDGET_DIR="${BUDGET_DIR:-.bundle-budgets}"
ensure_clean_git() {
pushd "$APP_DIR" >/dev/null
if [[ -n "$(git status --porcelain 2>/dev/null || true)" ]]; then
echo "ERROR: working tree is dirty. Commit or stash before measuring." >&2
echo "Reason: an iteration's result must be attributable to a single change." >&2
popd >/dev/null
exit 1
fi
popd >/dev/null
}
detect_next_version() {
pushd "$APP_DIR" >/dev/null
local v
v="$(jq -r '.dependencies.next // .devDependencies.next // empty' package.json 2>/dev/null || true)"
popd >/dev/null
echo "${v#^}" | awk -F. '{print $1}'
}
detect_bundler() {
pushd "$APP_DIR" >/dev/null
local major
major="$(detect_next_version || echo 0)"
if [[ "$major" -ge 16 ]]; then
echo "turbopack"
else
local turbo
turbo="$(jq -r '.scripts.build // empty' package.json | grep -c -- '--turbopack' || true)"
if [[ "$turbo" -gt 0 ]]; then
echo "turbopack"
else
echo "webpack"
fi
fi
popd >/dev/null
}
now_iso() { date -u +"%Y-%m-%dT%H-%M-%SZ"; }
human_bytes() {
awk -v b="$1" 'BEGIN{
units="B KB MB GB"; split(units,u," ");
i=1; while(b>=1024 && i<4){b/=1024; i++}
printf("%.1f %s\n", b, u[i])
}'
}
#!/usr/bin/env bash
# analyze.sh — Run the bundle analyzer and extract top offenders.
# Usage: bash scripts/analyze.sh [iteration-name]
# Defaults iteration-name to a timestamp. Output: iterations/{name}/findings.json
set -euo pipefail
source "$(dirname "$0")/_common.sh"
require_jq
cd "$APP_DIR"
ITER_NAME="${1:-$(now_iso)}"
OUT_DIR="$SKILL_DIR/iterations/$ITER_NAME"
mkdir -p "$OUT_DIR"
BUNDLER="$(detect_bundler)"
NEXT_MAJOR="$(detect_next_version)"
echo "→ Analyze run: $ITER_NAME ($BUNDLER, next $NEXT_MAJOR)"
if [[ ! -d .next ]]; then
echo "ERROR: .next/ missing. Run scripts/baseline.sh or scripts/measure.sh first." >&2
exit 1
fi
if [[ "$BUNDLER" == "turbopack" ]] && [[ "$NEXT_MAJOR" -ge 16 ]]; then
if [[ ! -d .next/diagnostics/analyze ]]; then
echo "→ Running next experimental-analyze --output"
npx next experimental-analyze --output
fi
if [[ -d .next/diagnostics/analyze ]]; then
cp -R .next/diagnostics/analyze "$OUT_DIR/analyze"
fi
else
echo "→ Turbopack analyzer not available (webpack mode or Next < 16)."
echo " Using build-manifest only. Install @next/bundle-analyzer for richer data."
fi
# Build findings.json — the structured input for diagnose.sh.
# We work from build-manifest data which is always available.
node - "$OUT_DIR" <<'NODE'
const fs = require('fs');
const path = require('path');
const outDir = process.argv[2];
const root = process.cwd();
function readJson(p){ try{ return JSON.parse(fs.readFileSync(p,'utf8')); }catch{ return null; } }
const buildManifest = readJson(path.join(root, '.next/build-manifest.json')) || {};
const appBuildManifest = readJson(path.join(root, '.next/app-build-manifest.json')) || {};
function fileSize(rel){
const p = path.join(root, '.next', rel.replace(/^\/_next\//,'').replace(/^_next\//,''));
try { return fs.statSync(p).size; } catch { return 0; }
}
// Per-chunk size index
const chunkSizes = {};
const seen = new Set();
function indexChunks(map){
for (const chunks of Object.values(map || {})) {
for (const c of (chunks || [])) {
if (seen.has(c)) continue;
seen.add(c);
chunkSizes[c] = fileSize(c);
}
}
}
indexChunks(buildManifest.pages);
indexChunks(appBuildManifest.pages);
// Chunks used by many routes (shared / framework)
const usage = {};
function tallyUsage(map){
for (const [route, chunks] of Object.entries(map || {})) {
for (const c of (chunks || [])) {
usage[c] = usage[c] || { routes: new Set(), bytes: chunkSizes[c] || 0 };
usage[c].routes.add(route);
}
}
}
tallyUsage(buildManifest.pages);
tallyUsage(appBuildManifest.pages);
const topChunks = Object.entries(chunkSizes)
.sort((a,b) => b[1] - a[1])
.slice(0, 20)
.map(([chunk, bytes]) => ({
chunk,
bytes,
used_by_routes: Array.from(usage[chunk]?.routes || []).slice(0, 8),
route_count: (usage[chunk]?.routes.size) || 0,
}));
// Heaviest routes
function routeWeights(map, prefix){
return Object.entries(map || {}).map(([route, chunks]) => ({
route: `${prefix}${route}`,
bytes: (chunks || []).reduce((a, c) => a + (chunkSizes[c] || 0), 0),
chunk_count: (chunks || []).length,
}));
}
const heaviestRoutes = [
...routeWeights(buildManifest.pages, 'pages:'),
...routeWeights(appBuildManifest.pages, 'app:'),
].sort((a,b) => b.bytes - a.bytes).slice(0, 15);
// Hints to feed diagnose.sh
const hints = [];
for (const c of topChunks.slice(0, 10)) {
if (c.route_count >= 3) hints.push({ kind: 'shared-heavy', chunk: c.chunk, bytes: c.bytes, route_count: c.route_count });
else hints.push({ kind: 'route-heavy', chunk: c.chunk, bytes: c.bytes, used_by_routes: c.used_by_routes });
}
// Treemap JSON, if Turbopack analyzer produced it (best-effort — schema is experimental)
const treemapJson = path.join(outDir, 'analyze', 'data.json');
let treemap = null;
if (fs.existsSync(treemapJson)) {
treemap = readJson(treemapJson);
}
fs.writeFileSync(
path.join(outDir, 'findings.json'),
JSON.stringify({
iteration: path.basename(outDir),
ts: new Date().toISOString(),
top_chunks: topChunks,
heaviest_routes: heaviestRoutes,
hints,
has_treemap: !!treemap,
}, null, 2)
);
NODE
# Build a structured bundle.json snapshot matching baseline format (for compare.sh)
node - "$OUT_DIR" <<'NODE'
const fs = require('fs');
const path = require('path');
const outDir = process.argv[2];
const root = process.cwd();
function readJson(p){ try{ return JSON.parse(fs.readFileSync(p,'utf8')); }catch{ return null; } }
const buildManifest = readJson(path.join(root, '.next/build-manifest.json')) || {};
const appBuildManifest = readJson(path.join(root, '.next/app-build-manifest.json')) || {};
function fileSize(rel){
const p = path.join(root, '.next', rel.replace(/^\/_next\//,'').replace(/^_next\//,''));
try { return fs.statSync(p).size; } catch { return 0; }
}
function sumRoute(chunks){ return (chunks||[]).reduce((a,c) => a + fileSize(c), 0); }
const routes = {};
for (const [route, chunks] of Object.entries(buildManifest.pages || {})) {
routes[`pages:${route}`] = { chunks: chunks.length, bytes: sumRoute(chunks) };
}
for (const [route, chunks] of Object.entries(appBuildManifest.pages || {})) {
routes[`app:${route}`] = { chunks: chunks.length, bytes: sumRoute(chunks) };
}
fs.writeFileSync(path.join(outDir, 'bundle.json'), JSON.stringify({ routes, ts: new Date().toISOString() }, null, 2));
NODE
echo ""
echo "✓ Findings saved to $OUT_DIR/findings.json"
echo ""
jq -r '
"Top chunks (largest):",
(.top_chunks[:5] | .[] | " \(.bytes | tostring) B \(.chunk) (×\(.route_count) routes)"),
"",
"Heaviest routes:",
(.heaviest_routes[:5] | .[] | " \(.bytes | tostring) B \(.route) (\(.chunk_count) chunks)")
' "$OUT_DIR/findings.json"
echo ""
echo "Next: bash scripts/diagnose.sh $OUT_DIR/findings.json"
#!/usr/bin/env bash
# baseline.sh — Establish the reference snapshot for bundle size + build time.
# Usage:
# bash scripts/baseline.sh # measure baseline
# bash scripts/baseline.sh --setup # interactively populate config.json
# bash scripts/baseline.sh --dry-run # print what would happen, no build
set -euo pipefail
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ "${1:-}" == "--setup" ]]; then
CONFIG_FILE="${CLAUDE_PLUGIN_DATA:-$SKILL_DIR}/config.json"
if [[ -f "$CONFIG_FILE" ]] && [[ -n "$(jq -r '.app_dir // empty' "$CONFIG_FILE" 2>/dev/null)" ]]; then
echo "config.json already populated at $CONFIG_FILE"
echo "Edit it directly to change values, or delete and re-run --setup."
exit 0
fi
echo "Setup is intended to be driven by the agent via AskUserQuestion."
echo "Required fields: package_manager, app_dir, build_command, test_command, typecheck_command, budget_dir"
echo "See config.json _setup_instructions for guidance."
exit 0
fi
source "$(dirname "$0")/_common.sh"
require_jq
DRY_RUN=0
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
cd "$APP_DIR"
ensure_clean_git
BUNDLER="$(detect_bundler)"
NEXT_MAJOR="$(detect_next_version)"
TS="$(now_iso)"
OUT_DIR="$SKILL_DIR/baselines/$TS"
echo "→ Baseline run: $TS"
echo " app_dir: $APP_DIR"
echo " bundler: $BUNDLER"
echo " next major: $NEXT_MAJOR"
echo " output: $OUT_DIR"
if [[ $DRY_RUN -eq 1 ]]; then
echo "(dry-run) would: rm -rf .next && $BUILD_CMD && save snapshot"
exit 0
fi
mkdir -p "$OUT_DIR"
echo "→ Clearing .next/ for a cold build (baseline must be reproducible)"
rm -rf .next
echo "→ Running production build"
BUILD_LOG="$OUT_DIR/build.log"
build_start=$(date +%s)
if ! bash -c "$BUILD_CMD" 2>&1 | tee "$BUILD_LOG"; then
echo "ERROR: baseline build failed. Fix the build before baselining." >&2
exit 1
fi
build_end=$(date +%s)
build_duration=$((build_end - build_start))
echo "→ Build OK in ${build_duration}s. Running bundle analyzer."
ANALYZE_OUT=".next/diagnostics/analyze"
if [[ "$BUNDLER" == "turbopack" ]] && [[ "$NEXT_MAJOR" -ge 16 ]]; then
if ! npx next experimental-analyze --output 2>&1 | tee -a "$BUILD_LOG"; then
echo "WARN: experimental-analyze failed; falling back to manifest-only snapshot." >&2
ANALYZE_OUT=""
fi
else
echo " Detected non-Turbopack project. Skipping experimental-analyze."
echo " For webpack mode, install @next/bundle-analyzer and re-run with ANALYZE=true."
ANALYZE_OUT=""
fi
# Capture artifacts the comparison step relies on.
if [[ -n "$ANALYZE_OUT" ]] && [[ -d "$ANALYZE_OUT" ]]; then
cp -R "$ANALYZE_OUT" "$OUT_DIR/analyze"
fi
# build-manifest.json: route → chunk list. Available for both bundlers.
[[ -f .next/build-manifest.json ]] && cp .next/build-manifest.json "$OUT_DIR/build-manifest.json"
[[ -f .next/app-build-manifest.json ]] && cp .next/app-build-manifest.json "$OUT_DIR/app-build-manifest.json"
# Sum chunk sizes per route from the manifest. This is what we'll diff iteration-over-iteration.
node - <<'NODE' > "$OUT_DIR/bundle.json"
const fs = require('fs');
const path = require('path');
const root = process.cwd();
function readJson(p){ try{ return JSON.parse(fs.readFileSync(p,'utf8')); }catch{ return null; } }
const buildManifest = readJson(path.join(root, '.next/build-manifest.json')) || {};
const appBuildManifest = readJson(path.join(root, '.next/app-build-manifest.json')) || {};
const staticDir = path.join(root, '.next/static');
function fileSize(rel){
const p = path.join(root, '.next', rel.replace(/^\/_next\//, '').replace(/^_next\//,''));
try { return fs.statSync(p).size; } catch { return 0; }
}
function sumRoute(chunks){
return (chunks||[]).reduce((acc, c) => acc + fileSize(c), 0);
}
const routes = {};
for (const [route, chunks] of Object.entries(buildManifest.pages || {})) {
routes[`pages:${route}`] = { chunks: chunks.length, bytes: sumRoute(chunks) };
}
for (const [route, chunks] of Object.entries(appBuildManifest.pages || {})) {
routes[`app:${route}`] = { chunks: chunks.length, bytes: sumRoute(chunks) };
}
process.stdout.write(JSON.stringify({ routes, ts: new Date().toISOString() }, null, 2));
NODE
# Timing snapshot
jq -n \
--arg ts "$TS" \
--arg bundler "$BUNDLER" \
--argjson build_seconds "$build_duration" \
'{ts: $ts, bundler: $bundler, build_seconds: $build_seconds, cache: "cold"}' \
> "$OUT_DIR/timing.json"
# Mark this as the current baseline (symlink for quick reference)
ln -sfn "$OUT_DIR" "$SKILL_DIR/baselines/current"
echo ""
echo "✓ Baseline saved to $OUT_DIR"
echo " build_seconds: ${build_duration}"
echo " routes: $(jq '.routes | length' "$OUT_DIR/bundle.json")"
echo ""
echo "Next: bash scripts/analyze.sh"
#!/usr/bin/env bash
# compare.sh — Diff a measurement against the baseline.
# Usage:
# bash scripts/compare.sh # latest iteration vs baselines/current
# bash scripts/compare.sh <baseline> <current> # explicit paths
# Exit codes:
# 0 = improvement or no change
# 1 = overall regression (per-route increases beyond budget OR total grew)
# 2 = baseline or current missing
set -euo pipefail
source "$(dirname "$0")/_common.sh"
require_jq
BASELINE_DIR="${1:-$SKILL_DIR/baselines/current}"
CURRENT_DIR="${2:-}"
if [[ -z "$CURRENT_DIR" ]]; then
CURRENT_DIR="$(ls -td "$SKILL_DIR"/iterations/*/ 2>/dev/null | head -n1 || true)"
fi
if [[ ! -f "$BASELINE_DIR/bundle.json" ]]; then
echo "ERROR: baseline bundle.json missing at $BASELINE_DIR" >&2
echo "Run: bash scripts/baseline.sh" >&2
exit 2
fi
if [[ -z "$CURRENT_DIR" ]] || [[ ! -f "${CURRENT_DIR%/}/bundle.json" ]]; then
echo "ERROR: current bundle.json missing." >&2
echo "Run: bash scripts/measure.sh" >&2
exit 2
fi
CURRENT_DIR="${CURRENT_DIR%/}"
echo "→ Comparing"
echo " baseline: $BASELINE_DIR"
echo " current: $CURRENT_DIR"
echo ""
# Per-route delta — show top changes by absolute size.
RESULT=$(
node - "$BASELINE_DIR/bundle.json" "$CURRENT_DIR/bundle.json" <<'NODE'
const fs = require('fs');
const b = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const c = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
const routes = new Set([...Object.keys(b.routes||{}), ...Object.keys(c.routes||{})]);
const deltas = [];
let totalB = 0, totalC = 0;
for (const r of routes){
const before = b.routes[r]?.bytes ?? 0;
const after = c.routes[r]?.bytes ?? 0;
totalB += before; totalC += after;
if (before === after) continue;
deltas.push({ route: r, before, after, delta: after - before, pct: before === 0 ? 100 : ((after - before)/before * 100) });
}
deltas.sort((a,b) => Math.abs(b.delta) - Math.abs(a.delta));
const overall = totalC - totalB;
const overallPct = totalB === 0 ? 0 : (overall / totalB * 100);
function fmt(n){ const s = n>=0?'+':''; if (Math.abs(n)>=1024*1024) return `${s}${(n/1024/1024).toFixed(2)} MB`; if (Math.abs(n)>=1024) return `${s}${(n/1024).toFixed(1)} KB`; return `${s}${n} B`; }
console.log(`Total: ${(totalB/1024).toFixed(1)} KB → ${(totalC/1024).toFixed(1)} KB (${fmt(overall)}, ${overallPct.toFixed(1)}%)`);
console.log('');
console.log('Top 15 route deltas:');
for (const d of deltas.slice(0, 15)){
const arrow = d.delta < 0 ? '↓' : '↑';
console.log(` ${arrow} ${fmt(d.delta).padEnd(12)} ${d.pct.toFixed(1).padStart(6)}% ${d.route}`);
}
console.log('');
// Timing delta if available
try {
const tb = JSON.parse(fs.readFileSync(process.argv[2].replace('bundle.json','timing.json'), 'utf8'));
const tc = JSON.parse(fs.readFileSync(process.argv[3].replace('bundle.json','timing.json'), 'utf8'));
const td = (tc.build_seconds||0) - (tb.build_seconds||0);
console.log(`Build time: ${tb.build_seconds}s → ${tc.build_seconds}s (${td>=0?'+':''}${td}s)`);
} catch {}
// Machine-readable summary for callers
process.stderr.write(JSON.stringify({ overall_bytes_delta: overall, overall_pct: overallPct, regressions: deltas.filter(d=>d.delta>0), wins: deltas.filter(d=>d.delta<0) }) + '\n');
process.exit(overall > 0 ? 1 : 0);
NODE
)
EXIT=$?
echo "$RESULT"
exit $EXIT
#!/usr/bin/env bash
# diagnose.sh — Map findings to optimization recipes.
# Usage: bash scripts/diagnose.sh [path/to/findings.json]
# Defaults to the most recent iteration's findings.json.
# Output: stdout — prioritized recipe list with pointers into references/optimizations.md.
set -euo pipefail
source "$(dirname "$0")/_common.sh"
require_jq
FINDINGS="${1:-}"
if [[ -z "$FINDINGS" ]]; then
FINDINGS="$(ls -td "$SKILL_DIR"/iterations/*/findings.json 2>/dev/null | head -n1 || true)"
fi
if [[ -z "$FINDINGS" ]] || [[ ! -f "$FINDINGS" ]]; then
echo "ERROR: no findings.json. Run scripts/analyze.sh first." >&2
exit 1
fi
echo "→ Diagnosing $FINDINGS"
echo ""
# Look at top chunks and heaviest routes, classify each, suggest a recipe.
# Classification is heuristic; the agent should still verify by inspecting
# the analyzer treemap or the import chain.
node - "$FINDINGS" <<'NODE'
const fs = require('fs');
const findings = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const recipes = [];
// Heuristic: very large shared chunk used by ≥3 routes → barrel-import or polyfill bloat
// Heuristic: huge route-specific chunk → check for accidental client component / heavy dep
// Heuristic: many small chunks → fine, ignore
function fmt(n){
if (n >= 1024*1024) return `${(n/1024/1024).toFixed(2)} MB`;
if (n >= 1024) return `${(n/1024).toFixed(1)} KB`;
return `${n} B`;
}
const SHARED_THRESHOLD = 100 * 1024; // 100 KB
const ROUTE_THRESHOLD = 200 * 1024; // 200 KB
const DOMINANT_CHUNK = 50 * 1024; // a single chunk >50KB is worth investigating
for (const c of findings.top_chunks) {
if (c.bytes < DOMINANT_CHUNK) continue;
const shared = c.route_count >= 3;
const isPolyfillish = /polyfill|core-js|legacy/i.test(c.chunk);
const isFramework = /framework|main|webpack-runtime|polyfills/i.test(c.chunk);
if (isFramework && !isPolyfillish) {
recipes.push({
priority: 'LOW',
finding: `${fmt(c.bytes)} framework chunk: ${c.chunk}`,
recipe: 'framework-chunks',
why: 'Framework chunks are mostly fixed cost. Only worth touching if extraordinarily large.',
});
continue;
}
if (isPolyfillish) {
recipes.push({
priority: 'HIGH',
finding: `${fmt(c.bytes)} polyfill-related chunk: ${c.chunk}`,
recipe: 'polyfill-bloat',
why: 'Polyfills compound across routes. Tightening browserslist usually has the biggest single-change impact.',
});
continue;
}
if (shared && c.bytes > SHARED_THRESHOLD) {
recipes.push({
priority: 'HIGH',
finding: `${fmt(c.bytes)} shared chunk used by ${c.route_count} routes: ${c.chunk}`,
recipe: 'shared-heavy-dep',
why: 'A heavy dep imported across many routes multiplies the cost. Investigate import chain in the analyzer treemap.',
});
continue;
}
if (!shared && c.bytes > ROUTE_THRESHOLD) {
recipes.push({
priority: 'MEDIUM',
finding: `${fmt(c.bytes)} route-specific chunk: ${c.chunk} (routes: ${c.used_by_routes.join(', ')})`,
recipe: 'heavy-client-component',
why: 'Could be a wrong-side import (server lib in client) or a candidate for next/dynamic + ssr:false.',
});
continue;
}
}
// Per-route weight checks
for (const r of findings.heaviest_routes.slice(0, 5)) {
if (r.bytes > 500 * 1024) {
recipes.push({
priority: 'HIGH',
finding: `${fmt(r.bytes)} route bundle: ${r.route}`,
recipe: 'large-route-bundle',
why: 'Routes over ~500KB hurt LCP/TTI on mobile. Open the analyzer treemap for this route and look at the largest module.',
});
} else if (r.bytes > 250 * 1024) {
recipes.push({
priority: 'MEDIUM',
finding: `${fmt(r.bytes)} route bundle: ${r.route}`,
recipe: 'large-route-bundle',
why: 'Approaching the warning threshold; opportunistic optimization worthwhile.',
});
}
}
if (recipes.length === 0) {
console.log('No obvious offenders. Bundle is in good shape.');
console.log('Consider build-time optimizations — see references/optimizations.md#build-time.');
process.exit(0);
}
// Sort by priority HIGH > MEDIUM > LOW
const order = { HIGH: 0, MEDIUM: 1, LOW: 2 };
recipes.sort((a,b) => order[a.priority] - order[b.priority]);
console.log('Recommended recipes (apply ONE at a time, then measure + verify):');
console.log('');
for (let i = 0; i < recipes.length; i++) {
const r = recipes[i];
console.log(`${i+1}. [${r.priority}] ${r.finding}`);
console.log(` Recipe: references/optimizations.md#${r.recipe}`);
console.log(` Why: ${r.why}`);
console.log('');
}
console.log('After applying one recipe:');
console.log(' bash scripts/measure.sh');
console.log(' bash scripts/compare.sh');
console.log(' bash scripts/verify.sh');
NODE
#!/usr/bin/env bash
# measure.sh — Re-build and re-measure after applying a change.
# Usage:
# bash scripts/measure.sh [iteration-name]
# bash scripts/measure.sh --warm # do NOT delete .next; measures incremental build
# Default: cold build (rm -rf .next) for apples-to-apples vs the baseline.
set -euo pipefail
source "$(dirname "$0")/_common.sh"
require_jq
cd "$APP_DIR"
WARM=0
ITER_NAME=""
for arg in "$@"; do
case "$arg" in
--warm) WARM=1 ;;
--dry-run) echo "(dry-run) would: build, analyze, write iteration snapshot"; exit 0 ;;
*) ITER_NAME="$arg" ;;
esac
done
ITER_NAME="${ITER_NAME:-$(now_iso)}"
OUT_DIR="$SKILL_DIR/iterations/$ITER_NAME"
mkdir -p "$OUT_DIR"
BUNDLER="$(detect_bundler)"
NEXT_MAJOR="$(detect_next_version)"
echo "→ Measure run: $ITER_NAME ($BUNDLER)"
if [[ $WARM -eq 0 ]]; then
echo " Cold build (rm -rf .next)"
rm -rf .next
else
echo " Warm build (reusing existing .next/cache)"
fi
BUILD_LOG="$OUT_DIR/build.log"
build_start=$(date +%s)
if ! bash -c "$BUILD_CMD" 2>&1 | tee "$BUILD_LOG"; then
echo "ERROR: build failed. This means the most recent change broke the build." >&2
echo "Action: revert and try a different recipe." >&2
jq -n --arg ts "$ITER_NAME" '{ts:$ts, status:"build_failed"}' > "$OUT_DIR/timing.json"
exit 1
fi
build_end=$(date +%s)
build_duration=$((build_end - build_start))
jq -n \
--arg ts "$ITER_NAME" \
--arg bundler "$BUNDLER" \
--argjson build_seconds "$build_duration" \
--arg cache "$([[ $WARM -eq 1 ]] && echo warm || echo cold)" \
'{ts:$ts, bundler:$bundler, build_seconds:$build_seconds, cache:$cache, status:"ok"}' \
> "$OUT_DIR/timing.json"
# Delegate the bundle snapshot + findings extraction to analyze.sh.
bash "$SKILL_DIR/scripts/analyze.sh" "$ITER_NAME"
echo ""
echo "✓ Measurement complete: $OUT_DIR"
echo " build_seconds: ${build_duration}"
echo ""
echo "Next: bash scripts/compare.sh"
#!/usr/bin/env bash
# verify.sh — Hard verification: build + types + tests + no overall regression.
# Usage: bash scripts/verify.sh
# Exit 0 = safe to commit. Exit 1 = revert.
set -euo pipefail
source "$(dirname "$0")/_common.sh"
require_jq
cd "$APP_DIR"
PASS=0
FAIL=0
# Note on arithmetic: ((PASS++)) returns exit 1 when PASS starts at 0 (post-increment
# of 0), which trips `set -e`. Use PASS=$((PASS+1)) instead.
run_check() {
local label="$1"; shift
echo "→ $label"
if "$@"; then
echo " PASS: $label"
PASS=$((PASS+1))
else
echo " FAIL: $label"
FAIL=$((FAIL+1))
fi
}
# 1. Build (cold for a fair comparison; skip if measure.sh already left a fresh .next)
if [[ ! -f .next/build-manifest.json ]] && [[ ! -f .next/app-build-manifest.json ]]; then
rm -rf .next
run_check "next build" bash -c "$BUILD_CMD"
else
echo "→ Skipping rebuild (fresh .next/ from measure.sh)"
PASS=$((PASS+1))
fi
# 2. Type check — counts as FAIL if it fails (no `|| true` swallowing).
run_check "typecheck ($TYPECHECK_CMD)" bash -c "$TYPECHECK_CMD"
# 3. Tests (only if a test command is configured AND there are test files)
if [[ -n "$TEST_CMD" ]]; then
if find . -maxdepth 4 -type d \( -name node_modules -o -name .next -o -name .git \) -prune -o \
-type f \( -name '*.test.*' -o -name '*.spec.*' \) -print -quit 2>/dev/null | grep -q .; then
run_check "tests ($TEST_CMD)" bash -c "$TEST_CMD"
else
echo "→ No test files detected; skipping $TEST_CMD"
PASS=$((PASS+1))
fi
fi
# 4. No overall bundle regression vs baseline
if [[ -d "$SKILL_DIR/baselines/current" ]]; then
echo "→ Bundle regression check vs baselines/current"
# compare.sh exits 1 on regression. Don't let set -e abort us before we record FAIL.
set +e
bash "$SKILL_DIR/scripts/compare.sh" >/dev/null
cmp_exit=$?
set -e
if [[ $cmp_exit -eq 0 ]]; then
echo " PASS: no overall regression"
PASS=$((PASS+1))
else
echo " FAIL: overall bundle grew. See: bash scripts/compare.sh"
FAIL=$((FAIL+1))
fi
else
echo "→ No baseline yet; skipping regression check"
fi
echo ""
echo "Results: $PASS passed, $FAIL failed"
if [[ $FAIL -gt 0 ]]; then
echo ""
echo "Verify FAILED. Recommended action: git revert the last change and try a different recipe."
exit 1
fi
echo ""
echo "Verify PASSED. Safe to commit."
echo "Suggested: git add -A && git commit -m 'optim: <recipe applied>'"
exit 0
Related skills
FAQ
What does nextjs-bundle-optimizer do?
nextjs-bundle-optimizer is a Claude Code skill for ai & agent building. It helps developers move faster with AI-assisted coding.
When should I use nextjs-bundle-optimizer?
When you need to helps with ai & agent building tasks during ai-assisted development, or when nextjs-bundle-optimizer is a claude code skill for ai & agent building. it helps developers move faster with ai-assisted coding.
What are the main capabilities?
nextjs-bundle-optimizer; AI & Agent Building; AI-coding skill.