Skip to main content
ASoc
Tutorial

React Bundle Size: The Framework Floor vs. the Part You Control

Bundle size is two numbers, not one. Measured here: a 195.2 KiB gzipped floor identical on every page, and the 68 KiB auth stack that used to sit inside it.

The ASoc Team10 min read

A React app's bundle size is two numbers, not one: a framework floor that is identical on every route, and a per-route delta that is the only part your code controls. On this storefront the floor is 195.2 KiB gzipped (650.7 KiB raw) and it is byte-identical on /blog, /docs, /license and every article page. Optimising "the bundle" means moving the delta.

Most advice on this topic reports one aggregate number, which is why it so often fails to reproduce. Below are the measured figures from a npm run build of this repository — 772 prerendered pages, Next.js 16.2.9 on Turbopack — plus the one change that actually moved the floor.

The short answer

QuestionAnswer here
What is the floor on every page?12 scripts, 650.7 KiB raw / 195.2 KiB gzipped
What is the heaviest route?/templates — 14 scripts, 869.3 KiB raw / 241.8 KiB gzipped
What is the lightest?/blog, /docs, /license — exactly the floor
Largest single chunkreact-dom, 221 KiB raw / 70 KiB gzipped
Total distinct chunks on disk29 files, 1,397 KiB raw / 366 KiB gzipped
Biggest win availableRemoving a library from the floor, not from a route

The last row is the whole point. A 40 KiB library on one route costs 40 KiB on one route. The same library imported at module scope by a site-wide component costs 40 KiB on all 772 pages.

Measure the floor and the delta separately

Next.js 16 with Turbopack no longer prints the "First Load JS" column that older guides tell you to read, so the number has to come off the build output directly. This is the measurement, run against .next after a production build:

# Sum the <script> chunks a prerendered page actually loads.
measure() {
  chunks=$(grep -oE '<script src="/_next/static/chunks/[A-Za-z0-9_.-]+\.js' "$1" \
    | sed 's|.*chunks/||' | sort -u)
  raw=0; gz=0
  for c in $chunks; do
    f=".next/static/chunks/$c"; [ -f "$f" ] || continue
    raw=$((raw + $(stat -c%s "$f")))
    gz=$((gz + $(gzip -9 -c "$f" | wc -c)))
  done
  printf "%-16s raw %6.1f KiB  gzip %5.1f KiB\n" "$2" \
    $(echo "$raw/1024" | bc -l) $(echo "$gz/1024" | bc -l)
}

measure .next/server/app/license.html   "/license"
measure .next/server/app/index.html     "/"
measure .next/server/app/templates.html "/templates"

Which prints, on this repo:

RouteScriptsRawGzipped
/license12650.7 KiB195.2 KiB
/docs12650.7 KiB195.2 KiB
/blog12650.7 KiB195.2 KiB
/blog/react-jsx12651.0 KiB195.3 KiB
/contact12652.9 KiB195.6 KiB
/pricing12655.2 KiB196.5 KiB
/14867.3 KiB240.7 KiB
/templates14869.3 KiB241.8 KiB

Three static content routes land on the same byte count to one decimal place. That is the signature of a floor: those pages ship no interactive code of their own, so what remains is React, the Next.js runtime and the router. Everything above 195.2 KiB is a route's own cost, and it is small — /contact carries an entire working form for 0.4 KiB gzipped over the floor, because the form is one client component and nothing else on the page hydrates.

If you want the per-module attribution behind these totals rather than per-route sums, that is a different tool and this repo has a separate write-up for it: the Next.js bundle analyzer.

The change that moved the floor

The floor is where the real defect lived. @supabase/ssr plus auth-js is ~68 KiB gzipped (255 KiB parsed), and it was reaching every page on the site. Two call sites did it:

  • Header renders site-wide.
  • useOwnedProducts renders behind every templates grid.

Both imported createClient at module scope. Module-scope imports are not conditional, so the whole auth stack sat in the initial bundle of /, /blog, /docs and /pricing — pages with no account UI at all — and had to be downloaded and parsed before the page could settle.

The fix is that both call sites only ever touch the client inside an effect, so the import can wait for the effect too. src/lib/supabase/lazyClient.ts:

/** Whether this browser is carrying a Supabase session cookie. */
export function hasAuthCookie(): boolean {
  if (typeof document === "undefined") return false;
  return /(?:^|;\s*)sb-.+?-auth-token/.test(document.cookie);
}

/** Dynamically imports the Supabase browser client and constructs it. */
export async function loadSupabaseClient(): Promise<BrowserClient> {
  const { createClient } = await import("@/lib/supabase/client");
  return createClient();
}

Two separate savings are stacked there, and they are worth separating because only one of them is the familiar advice:

  1. The dynamic import() moves the auth stack out of the initial chunk into one fetched on demand. Classic code splitting.
  2. The cookie probe skips it entirely for signed-out visitors. @supabase/ssr stores its session in cookies named sb-<project-ref>-auth-token, and they are deliberately not HttpOnly — the browser client reads them from document.cookie itself — so a regex over document.cookie sees exactly what the client would. No cookie means there is no session for the auth stack to find, so it is never fetched at all.

The second step is the one people skip. Code splitting defers a cost; a cheap precondition check avoids it. For a public storefront where most visitors are signed out, "avoid" beats "defer" on almost every page view.

You can verify the result rather than trusting it. Grep the chunks the home page actually loads for the library's fingerprint:

for c in $(grep -oE '<script src="/_next/static/chunks/[A-Za-z0-9_.-]+\.js' \
             .next/server/app/index.html | sed 's|.*chunks/||' | sort -u); do
  grep -l "GoTrueClient" ".next/static/chunks/$c"
done
# (no output — the auth stack is absent from /)

A guard like that belongs in CI if the import is load-bearing for your performance budget, because the regression is silent: adding one top-level import to a shared component puts the library back on every route and nothing fails.

What the delta buys on the heavy routes

/ and /templates are the two routes above the floor, and the extra weight is the templates grid:

ChunkRawGzipped
Grid + card interactivity202.5 KiB41.0 KiB
Preview modal support25.7 KiB8.5 KiB
Remainder15.7 KiB5.4 KiB

That is ~46 KiB gzipped for the preview modal, the wishlist button, and the owner download menu on every card. It is a real cost and it is deliberately kept: those three are what the page is for.

The discipline that keeps it from being worse is counting client components instead of auditing them. There are 92 component files under src/components, and 24 of them are "use client" — the rest are Server Components that contribute zero bytes to these numbers. The useful habit is that the boundary is a decision you make once per component and can grep for:

grep -rl '"use client"' src/components | wc -l   # 24
find src/components -name '*.tsx' | wc -l       # 92

The clearest case of that boundary paying for itself is the sibling rail that closes every product page. It shows six same-category products, and it renders RelatedTemplateCard — a Server Component — rather than reusing TemplateCard. TemplateCard is a client component carrying a preview modal, a wishlist button and an ownership lookup; reusing it would have shipped all three six times per product page for what is only a navigation rail. Two components that look like duplication in the file tree are a bundle decision.

The same instinct drives src/lib/useOwnedProducts.ts. A grid renders a dozen cards and each wants to know whether the viewer owns that product. Asking per card would be a dozen getUser() round trips plus a dozen server actions returning the same answer, so the promise is memoised at module scope:

let lookupPromise: Promise<Omit<OwnedProducts, "status">> | null = null;

function loadOwnership(): Promise<Omit<OwnedProducts, "status">> {
  lookupPromise ??= (async () => {
    // No session cookie: skip the auth stack entirely.
    // ...
  })();
  return lookupPromise;
}

That is not a bundle saving — it is a request saving — but it belongs in the same audit, because the reason you cut bundle size is time-to-interactive, and a dozen duplicate round trips cost that too.

Where bundle size stops being the bottleneck

Worth knowing before you spend a week on this: on the mobile Lighthouse run of this site, the LCP element on / and /templates resolves to text, and for a text LCP the estimate tracks total JavaScript execution, not bytes on the wire.

That was tested directly. With every image and every RSC prefetch blocked, home's LCP moved 3786 ms → 3740 ms — 46 ms out of 3.8 seconds. The remaining cost is React hydration, and the largest single chunk is react-dom itself at 221 KiB raw / 70 KiB gzipped. That is the framework floor for an interactive storefront, and getting past it would mean removing the interactivity the storefront exists to provide.

So the honest ceiling: once your delta is small and your floor is mostly react-dom, further bundle work buys very little. The next lever is shipping fewer client components, not smaller ones.

Mistakes and troubleshooting

SymptomCauseFix
No "First Load JS" table in the build outputNext.js 16 + Turbopack no longer prints itSum the <script> chunks from the prerendered HTML, as above
A library you code-split still appears in the initial chunkSomething else imports it at module scopeGrep for the import across all components, not just the one you changed
Every route grew by the same amountThe import landed in a site-wide component (header, layout, provider)Move it behind an effect or a route group
import() inside a component body, bundle unchangedA static import of the same module still exists in that fileRemove the static import; one is enough to defeat the split
Gzipped numbers disagree with your CDN'sgzip -9 is not BrotliCompare like with like, or measure with brotli -q 11
Bundle shrank, Lighthouse did not moveThe LCP element is text, so execution dominates bytesCut client components, not kilobytes

Frequently asked questions

How big is React itself? On this build, react-dom is the largest single chunk at 221 KiB raw / 70 KiB gzipped, and the full shared floor including the Next.js runtime and router is 650.7 KiB raw / 195.2 KiB gzipped. Quoted figures for "React" vary wildly because some count only react, some add react-dom, and some are pre-gzip.

Is a 195 KiB gzipped floor bad? It is the cost of an interactive React app with a router, and it is cacheable across every route. The number worth watching is not the floor but whether it grows — a floor that drifts upward means a library landed in a shared component.

Does code splitting help a static marketing page? Less than you would hope, because the floor dominates. The bigger win on a static page is having fewer client components at all, so there is nothing to hydrate; this site's /license and /docs ship zero interactive code and land exactly on the floor.

Should I switch frameworks to cut bundle size? Only if interactivity is genuinely optional for your pages. The measurement above shows the floor is framework runtime and the delta is your own interactive features — a framework change moves the first number and leaves the second, so it pays off only when the first one is your actual problem.

Templates in this post

ASoc Reach is an AI-marketing agency landing page with services, achievements and team sections. ASoc Realm is a property-management SaaS marketing site with features, use-case sectors and a journal. ASoc Relay is a messaging-platform SaaS site covering an omnichannel inbox, AI replies and integrations.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Tutorial9 min read

React Caching: 2 useMemo Calls, and the One Cache That Mattered

Five caching layers, and the render layer is rarely the one that pays. Nine lines of module-scope promise removed a dozen round trips per page load.

Read more
Tutorial8 min read

React Carousel: Two Patterns, Zero Dependencies, One Bug We Fixed

A passive CSS marquee and a navigable translated-track gallery, both dependency-free — plus the missing prefers-reduced-motion guard this audit found and fixed.

Read more
Tutorial10 min read

React Contact Form: Pick Who Sends the Mail First

React cannot send email, so the only real decision is who does. Three routes compared, plus the 40-line action this site runs — honeypot, reply_to and error hygiene.

Read more