
Build Note
Keeping First Load JS under budget in Next.js
A number in the route table is the gate: server components by default, no animation library in the layout path, and scroll motion done in about 30 lines of CSS.
Published August 4, 2026
Pick a First Load JS number, read it out of the build's route table after every change, and treat crossing it as a failure to fix rather than a number to update. Then the rules that keep you under it — server components by default, no animation library in the layout path — have something to be accountable to.
The budget is a line in the build output
Next.js prints First Load JS per route at the end of every production build. That table is the enforcement mechanism: after any change, the homepage figure gets read, and if it has moved past the budget the change is not finished. On this site the ceiling is 115 kB and the current homepage measures 112 kB across 55 static pages.
Route (app) Size First Load JS
┌ ○ / 715 B 112 kB
├ ○ /knowledge-base 2.1 kB 113 kB
├ ○ /services/web-development 270 B 138 kB
+ First Load JS shared by all 87.5 kBNote the shared baseline: 87.5 kB is the framework itself, arriving on every route no matter what you do. The homepage adds about 24 kB on top of that. Knowing which part of the number you can actually move stops the budget from being an argument about React's size.
Rule one: server components until proven otherwise
Every component is a server component unless it needs state, effects, or event handlers. That's the default position, and the reason the homepage ships 715 B of its own JavaScript: the hero, the section copy, the proof blocks, and the illustrations are all HTML by the time they reach the browser.
The expensive mistake is marking something "use client" high in the tree for one small piece of interactivity. The directive is not local — it moves that component and everything it renders into the client bundle. When a layout component needs a click handler, the fix is to push the interactive part down into its own small client component and leave the rest on the server.
Rule two: no animation library in the layout path
Anything imported from app/layout.tsx — the header, the footer, the mobile navigation — ships on every route in the site. An animation library imported there is the single most expensive accidental dependency available, because it is paid for by pages that animate nothing.
So the mobile navigation uses CSS transitions on purpose rather than a motion library, and the rule is written down: no framer-motion in anything reachable from the layout. It is allowed on secondary pages that already use it, where the cost lands on the route getting the benefit. That is the whole policy — not "avoid libraries", but "scope them to where they earn their weight".
What the cheap version looks like
The arming script is the important half. Content ships visible, and a synchronous inline script adds a class to <html> before first paint — only when JavaScript is available and the visitor has not asked for reduced motion. Anyone outside that set simply sees the page, with no flash of hidden content and nothing withheld from assistive technology or crawlers.
{/* Arm scroll reveals before first paint — only when JS is on AND motion
is allowed. No-JS / reduced-motion users keep content visible. */}
<script
dangerouslySetInnerHTML={{
__html:
"try{if(!matchMedia('(prefers-reduced-motion: reduce)').matches){document.documentElement.classList.add('js-reveal')}}catch(e){}",
}}
/>.reveal {
transition:
opacity var(--reveal-duration) var(--reveal-ease),
transform var(--reveal-duration) var(--reveal-ease);
}
/* Armed only when JS + motion are available; otherwise content stays visible. */
.js-reveal .reveal:not(.is-revealed) {
opacity: 0;
transform: translateY(var(--reveal-distance)) scale(var(--reveal-scale, 1));
will-change: opacity, transform;
}
.reveal.is-revealed {
opacity: 1;
transform: none;
will-change: auto;
}The component itself is an IntersectionObserver that sets one boolean and unobserves. Fading opacity is safe here — this is content entering the viewport, not text animating in place, which is a different problem with a sharper edge.
Rule three: illustrations that cost nothing
The homepage carries a schematic beside the headline and it added no JavaScript, because it is inline SVG rendered on the server. Two details make that safe above the fold: inline SVG shapes are not LCP candidates, so the text stays the largest contentful paint; and the whole thing is hidden below the large breakpoint, so a mobile throttled audit never paints it at all.
Third-party embeds are the opposite trade and are simply not used — the Content-Security-Policy blocks external origins outright, which means no analytics snippet, no font host, no video embed can be added without a deliberate decision. The header and its trade-offs are worth reading if that sounds restrictive; in practice it is the cheapest performance guarantee available.
Why a budget beats good intentions
Bundle size regressions are never one bad decision. They are twenty reasonable ones, each defensible in isolation, none of which anyone measured. A number in the build output converts that into a visible event: the figure moved, so something got added, so someone decides whether it was worth it — while the change is still fresh and cheap to undo.
The same principle underneath fixing Core Web Vitals applies here: enforce during the build rather than audit after it. A budget you check every time is worth more than a quarterly performance review of a site that has already slowed down.
A budget is only as good as the habit of reading it. Enforcing one from the first commit is part of how I build sites, not something retrofitted when the numbers get embarrassing.
Questions
What number should the budget be?
Whatever your current best page measures, rounded up slightly — the value matters less than having one. A budget's job is to make the next regression a decision rather than an accident, and any number does that. Tighten it when you beat it; never quietly raise it to accommodate a library.
Isn't this just avoiding useful libraries?
It's confining them. An animation library is fine on a secondary page that already needs it; the rule is that it never enters the layout path, because anything imported there ships on every route including the ones that needed nothing. Scope the cost to the pages that get the benefit.
Do inline SVGs count against the budget?
Not against First Load JS — they're markup, not script. They do add HTML weight, so a large decorative one is worth hiding at small viewports where it isn't shown anyway. And inline SVG shapes aren't LCP candidates, so a schematic beside a heading doesn't take the largest-paint slot from your text.
Where does the budget usually get blown?
A client component high in the tree, and a date or icon library imported for one call site. Both look local and aren't: marking a layout component "use client" drags its children's JavaScript to the browser too, and a single helper import can pull in a package many times the size of the code using it.
Related notes
- FAQ accordions that crawlers can actually read
- Keeping 100 Lighthouse scores behind a strict CSP
- How to fix failing Core Web Vitals
More on web development
- Claude Code rules for Elementor sites
- Claude Code rules for WCAG testing
- Cursor rules for WCAG testing
- Animating hero text without layout shift
Related service: Web Development
Want this kind of engineering on your project?
Tall Karol takes on fractional and project-based engagements for startups and agencies.
Book a working session