Two Imports That Were Shipping to Every Page of Our Next.js App

· Harsh Gupta

On a high-traffic site, every kilobyte of JavaScript on the busiest pages costs you, and most of our visitors use mid-range Android phones. So when I ran a bundle analysis on our Next.js 16 app, I wasn't hunting for big dependencies. I was looking for anything in First Load JS, the JavaScript every page downloads and runs before you can use it.

Two things stood out, and neither was something anyone had added as heavy code.

How to look

Next.js has a bundle analyzer plugin. With @next/bundle-analyzer added to next.config, run:

ANALYZE=true next build

The treemap shows you what's big. To check what actually loads first, look at the build manifests:

  • Chunks listed in build-manifest.json load up front on every page.
  • Chunks that appear only in react-loadable-manifest.json are lazy. They load through a dynamic import().

That's how I checked both fixes below. If something disappears from the first manifest, it's out of First Load JS.

1. The Sentry SDK (~290 KB)

We need error monitoring. But the Sentry browser SDK is about 290 KB, and it was getting into the first bundle in two different ways:

  1. instrumentation-client.ts imported it directly and called Sentry.init() at startup.
  2. A small captureRouteError helper imported it, and every route's error.tsx imported that helper. Error boundaries are part of First Load JS, so Sentry came along on every page.

Fixing only one of these would have changed nothing. Both had to become dynamic imports.

At startup, load Sentry after the page has loaded and the browser is idle, and only in production when a DSN is set:

// instrumentation-client.ts function loadSentry() { import("@sentry/nextjs").then((Sentry) => { Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN /* ... */ }); }); } const isProd = process.env.NODE_ENV === "production"; if (isProd && process.env.NEXT_PUBLIC_SENTRY_DSN) { window.addEventListener("load", () => { if ("requestIdleCallback" in window) requestIdleCallback(loadSentry); else setTimeout(loadSentry, 1); }); }

In error boundaries, import Sentry only when an error actually reaches one:

export async function captureRouteError(error: Error) { const Sentry = await import("@sentry/nextjs"); Sentry.captureException(error); }

The trade-off: we lose tracing for page changes that happen before the browser goes idle. We don't lose any errors. That was an easy choice for us, but make it on purpose, not by accident.

After the change, Sentry lives in lazy chunks that appear only in react-loadable-manifest.json, outside First Load JS.

2. Zod, twice (~131 KB)

This one was harder to spot. We had a clientEnv.ts file that checked public environment variables with a Zod schema. Fine on its own, but about 90 files in the client code imported it.

When a file is imported that widely, Next.js puts it in the shared chunk that every page loads. So Zod was downloaded and run on the home, listing, and detail pages, and none of them validate anything with it. A second full copy of Zod had also ended up in the Pages Router main chunk. Together that was about 131 KB.

The fix was to stop using a validation library for what is really a list of required keys:

// clientEnv.ts const values = { API_URL: process.env.NEXT_PUBLIC_API_URL, SITE_URL: process.env.NEXT_PUBLIC_SITE_URL, // ... }; for (const [key, value] of Object.entries(values)) { if (!value) throw new Error(`Missing env var: NEXT_PUBLIC_${key}`); } export const clientEnv = values as Record<keyof typeof values, string>;

Watch out for one thing here. Next.js replaces NEXT_PUBLIC_* values at build time only when you write them out in full, like process.env.NEXT_PUBLIC_API_URL. If you loop over keys and read process.env[key], nothing gets replaced and every value is undefined in the browser. So each value is written out in full, and only the check uses a loop.

Our forms still use Zod, but now it ships only with the pages that need it. Zod is gone from the shared chunk, which now holds only Next.js and its helpers, and it's down to one 67.6 KB lazy chunk.

3. The rest: load only what's on screen

Those were the two big finds. The rest is a habit. Anything below the fold, behind a click, or only for some visitors loads with next/dynamic. On our site that's about 30 components, for example:

  • Listing pages: filter and sort panels, reviews, FAQ, and the page description
  • Detail pages: reviews and review images, recommendations, and extra offers
  • Payments: each payment method's section, the address form, and the success animation
  • Everywhere: the login modal, saved-items and notify dialogs, the search panel, and the tracking scripts
const Reviews = dynamic(() => import("./reviews"));

Our rule is simple: if it isn't on screen when the page first shows, it doesn't go in First Load JS.

The other half is optimizePackageImports in next.config. We list about 30 packages there, including lucide-react, date-fns, motion, and every Radix component we use. With that, an import like import { X } from "lucide-react" pulls in only that one icon, not the whole package.

What I took away

  1. Shared files are where the weight hides. Nobody adds 131 KB on purpose. It comes in through a small file that everything imports.
  2. Check every import path. Sentry had two ways into the bundle. Closing only one would have shown no change and made the fix look broken.
  3. Check the manifests, not just the treemap. "It's in a separate chunk" and "it doesn't load first" are not the same thing.
  4. Make splitting a habit, not a cleanup job. If it isn't on screen at first, load it later.
  5. Write down the trade-off. Loading Sentry late costs us early page-change traces. That's fine, as long as it's a decision and not a surprise.
GitHub
LinkedIn