How We Cut 107 KB of Dead Weight From a Next.js Client Bundle
A seed-data module was shipping to every visitor because client components imported it for two field lookups. The fix was a server-only data layer and four small client islands.
Verified as of 12 August 2026. Figures in this article are measurements from TechPari's own codebase, taken from
next buildoutput before and after the change.Disclosure: this is a first-hand engineering account of work performed on this site. Your numbers will differ. The method transfers; the figures do not.
The problem
A routine bundle inspection turned up something ugly. Two files -
1lib/db.ts 65,595 bytes2lib/draft_articles.ts 41,852 bytes- roughly 107 KB of raw source, were reaching the browser on every public page.
Not the compiled output of the site. The seed data: full article bodies, draft articles never published, site settings, navigation config. Shipped to every visitor on every route.
The cause was mundane, which is why it survived so long.
How 107 KB gets into a client bundle
The site's data layer exported a single object with methods for reading content. Several client components imported it for genuinely small reasons:
1// components/Header.tsx2"use client";3import { TechPariDB } from "@/lib/db"; // ← the entire data module45export function Header() {6 const categories = TechPariDB.getCategories(); // needs: name + slug7 // ...8}The Header needed a name and a slug for each nav link. Importing the module to get them pulled in everything the module could reach - including every article body via a transitive import of the drafts file.
This is the standard failure mode. A "use client" boundary means every value the file imports must be serialised and sent, whether or not the component uses it. Bundlers cannot tree-shake what a module-level object might touch at runtime.
Six client components did versions of the same thing: Header, Footer, the homepage content component, the article card, search, and the tech-news list.
The fix
Three moves, in order of impact.
1. A server-only data module returning narrow shapes
Rather than exposing the whole data layer, we added a module that server components call and that returns only the fields a given page renders.
1// lib/data.server.ts - never imported by a "use client" file23export type CategoryListItem = {4 id: string;5 name: string;6 slug: string;7 description: string;8};910export function getNavCategories(): CategoryListItem[] {11 return TechPariDB.getCategories()12 .filter((c) => c.homepageVisibility)13 .map(({ id, name, slug, description }) => ({ id, name, slug, description }));14}The naming convention is deliberate. .server.ts makes an accidental client import obvious in review.
2. Pass data down as props
Client components stopped fetching and started receiving.
1// app/layout.tsx - Server Component2import { getNavCategories, getFooterData } from "@/lib/data.server";34export default function RootLayout({ children }) {5 return (6 <SiteLayout navCategories={getNavCategories()} footerData={getFooterData()}>7 {children}8 </SiteLayout>9 );10}1// components/Header.tsx2"use client";3import type { CategoryListItem } from "@/lib/data.server"; // type-only45export function Header({ navCategories }: { navCategories: CategoryListItem[] }) {6 // no data-layer import at all7}The import type matters. Type-only imports are erased at compile time and contribute nothing to the bundle. You keep full type safety and ship none of the module.
3. Split the article page into a server body and client islands
The article page was one large "use client" component, so the entire article - body, metadata, hero, related links - was client-rendered despite being fully static per request.
We converted it to a Server Component and extracted only the genuinely interactive parts:
| Island | Why it must be a client component |
| :--- | :--- |
| ReadingProgress | Scroll listener |
| ShareButtons | Clipboard and window.location |
| FAQAccordion | Expand/collapse state |
| CommentForm | Form state |
Everything else - including the markdown rendering - moved to the server.
Two details worth copying. The reading progress bar writes directly to a ref instead of React state:
1useEffect(() => {2 const update = () => {3 const h = document.documentElement.scrollHeight - window.innerHeight;4 if (barRef.current) {5 barRef.current.style.width = h > 0 ? `${(window.scrollY / h) * 100}%` : "0%";6 }7 };8 window.addEventListener("scroll", update, { passive: true });9 return () => window.removeEventListener("scroll", update);10}, []);No state, no re-render per scroll event, passive: true so it never blocks scrolling.
And card entrance animations moved from a JS animation library to CSS:
1@keyframes fadeInUp {2 from { opacity: 0; transform: translateY(20px); }3 to { opacity: 1; transform: translateY(0); }4}5@media (prefers-reduced-motion: reduce) {6 @keyframes fadeInUp { from { opacity: 0; } to { opacity: 1; } }7}Identical visual result, zero JavaScript, and reduced-motion support essentially free.
Measured results
From next build, First Load JS per route:
| Route | Before | After | Change | | :--- | ---: | ---: | ---: | | Homepage | 159 kB | 132 kB | −27 kB | | Article | 192 kB | 154 kB | −38 kB | | Category | 143 kB | 117 kB | −26 kB | | Tech news list | 140 kB | 114 kB | −26 kB | | Search | 142 kB | 115 kB | −27 kB |
The article page's own chunk fell to 139 bytes - the server now emits the HTML and the browser loads only the islands.
We verified elimination directly rather than trusting the totals, scanning built chunks for a string unique to the seed data:
1grep -l "tp_articles_v3" .next/static/chunks/*.jsEmpty for all public routes. Still present for the admin route, which legitimately needs the data layer.
What we deliberately did not do
We did not delete the data layer. Admin still uses it, correctly, server-side. The goal was removing it from public client bundles, not removing it.
We did not convert everything to Server Components. Header and Footer remain client components because they own real interactivity - a mobile drawer and an expanding search field. Passing them data as props was sufficient.
We did not chase the last kilobyte. The product detail route still pulls the data layer for related-article lookups. It is a low-traffic page and the fix is a larger refactor. Logged, not done.
Transferable checklist
- Find what is actually in your bundles. Search built chunks for a string unique to your largest data module. Route totals hide the cause.
- Audit every
"use client"file's imports. Ask what each import pulls transitively. - Convert value imports to
import typewherever you only need the shape. - Return narrow shapes from server code. Do not hand a client component an object with more fields than it renders.
- Split large client components into a server body plus islands. Interactivity is usually a small fraction of the tree.
- Replace JS-driven animation with CSS where the effect does not need JavaScript.
- Verify by grep, not by vibes. Confirm the module is gone.
The general principle: a "use client" boundary is a serialisation boundary. Anything reachable across it ships. Most oversized client bundles are one convenient import away from being reasonable.

No Comments
Add Your Comment