skip to content

You move date formatting out of a Client Component and into a Server Component in a Next.js App Router app, but `next build` reports the same First Load JS for that route and the date library still shows up in the client chunks. What are the likely causes, and how do you track it down?

level: seniorimportance: should knowfreq 40%

answer

  1. the bundler follows imports, not intent
  2. one client importer is enough
  3. who else imports that helper?
  4. a barrel-ish util module bridges it
  5. pass the formatted string, not the formatter

basics

~20 s

Something on the client side still imports the library. The bundler follows imports, not intent: one import path from any 'use client' entry point — usually through a shared helper module — keeps the package in the browser chunks no matter what the server page does.

solid answer

~50 s

The client bundle is built from the import graph rooted at every `'use client'` entry point, so a single surviving client-side import path keeps the library in. The three causes I would check in order: another Client Component still imports the same helper or package directly; the shared `lib/format.ts` you kept calling from the server also gets imported by a client module, dragging the whole date library across; or the component you "moved to the server" is itself rendered from a file with `'use client'` above it, so it was never a Server Component at all. To find it, search the codebase for importers of the package, then walk upward to see which of them sits under a client entry point. The fix is usually to split the helper module so the client side imports only the small part, and to pass an already-formatted string as a prop instead of the raw value plus a formatter.

code

typescript · 13 lines
typescript
// BEFORE — one module, so any client importer drags date-fns along
// lib/format.ts
import { format } from 'date-fns'
export const titleCase = (s: string) => s.toUpperCase()
export const formatDate = (d: Date) => format(d, 'PPP')

// AFTER — split along the boundary you want
// lib/format-text.ts  (client-safe)
export const titleCase = (s: string) => s.toUpperCase()

// lib/format-date.ts  (server-only callers)
import { format } from 'date-fns'
export const formatDate = (d: Date) => format(d, 'PPP')

go deeper

for a junior

Know that the client bundle is decided by which files import a module, not by where you call it. If any client component still imports the library, the bytes stay.

for a middle

Explain the graph: client chunks are built from every 'use client' entry point downward, so a shared helper importing the library bridges it into the browser. Describe splitting the helper module as the fix.

for a senior

Show a diagnosis order — confirm the number in next build, enumerate importers, walk each chain up to its entry point, verify in the loaded chunks — and name the structural repair rather than a one-off deletion.

for a principal

Own the guardrail: per-route JavaScript budgets checked in CI, a convention that separates client-safe helpers from server-only ones, and the review rule that keeps heavy dependencies from re-entering through a shared module.

## Why the intuition fails People reason about Server Components in terms of *where the call happens*. The bundler reasons about *which modules are reachable*. Those are different questions, and the bundle only ever answers the second one. Moving a call site changes nothing if some other file still imports the module. ## Cause 1: a surviving client importer The most common case. `lib/format.ts` exports `formatDate` and `formatCurrency`, and imports a 70 kB date library to do it. You changed the product page to format on the server — but the checkout widget is a Client Component and still calls `formatDate`. The bundler follows `checkout.tsx` → `lib/format.ts` → the date library, and emits all of it. One importer is enough. Removing the ninth of ten client-side usages changes nothing; the bundle shrinks only when the last edge is cut. ## Cause 2: the helper module is a bridge Even when no client component wants the date library, it can arrive as a passenger. A `lib/format.ts` that exports both a tiny `titleCase` and a date formatter is a single module: importing `titleCase` from a client component pulls the module in, and with it the date import at the top. Tree shaking helps only when the bundler can prove the unused branch has no side effects, which large date and i18n packages routinely defeat. The fix is structural — split the module along the boundary you actually want: ```ts // lib/format-text.ts -> safe for client imports export const titleCase = (s: string) => s.replace(/\b\w/g, (c) => c.toUpperCase()) // lib/format-date.ts -> imported only from Server Components import { format } from 'date-fns' export const formatDate = (d: Date) => format(d, 'PPP') ``` ## Cause 3: it never became a Server Component Worth ruling out early. If the file you edited is imported by a module with `'use client'` at the top — or has the directive itself, perhaps inherited from an earlier refactor — then the whole subtree is client code and the "move" was cosmetic. The tell is that the component uses no `await` and yet still works with hooks; a genuine Server Component cannot. ## Cause 4: you moved the data, not the work Sometimes the server component now fetches the values and passes raw `Date` objects down, while the client component still calls the formatter on them. The code moved half a step. The rule of thumb: pass the *finished string* across the boundary, not the value plus the tool that formats it. ## How to actually find it Work from the evidence rather than by inspection: 1. **Confirm the number.** Compare `next build` output for the route before and after — First Load JS per route is the metric that matters, and the shared baseline row tells you whether the library sits in the common chunk or a route chunk. 2. **Find every importer.** Grep the repository for the package name and for the helper module. This is unglamorous and it is what finds the answer most of the time. 3. **Walk upward from each importer.** For each one, follow the chain of importing files until you reach a file with `'use client'` at the top or reach a route file with none. Any chain that terminates at a client entry point explains the bytes. 4. **Verify in the browser.** Search the loaded client chunks in DevTools for a distinctive string from the library. If it is there, an import path exists; if it is gone, you are done. ## The durable fix Once found, the repair is usually one of: split the shared module so client code imports only what it needs; move the last client caller's formatting to its server parent and pass the string down; or, when a client component genuinely needs the capability, accept the cost knowingly and confirm it is on a route where the budget allows it. To keep it from regressing, the honest answer is that this needs a check, not discipline: record First Load JS per route in CI and fail the build when a route crosses its budget. Grep-and-hope does not survive a growing team.

  • Why doesn't tree shaking remove the unused date formatter from that shared helper module?
    Tree shaking can drop an unused export only when it can prove the code has no side effects. Large date and i18n packages commonly register locales, mutate globals, or ship in a form the bundler cannot analyse, so the import is retained conservatively. Splitting the module along the boundary is more reliable than hoping the analysis succeeds.
  • How can you tell whether the file you edited is genuinely a Server Component?
    Check the whole chain: the file must have no `'use client'` directive and must not be imported by any file that has one. A practical tell is that a Server Component can be `async` and `await` data directly while it cannot use hooks or event handlers — if the component uses `useState`, it is client code whatever you intended.
  • How would you stop this regression from coming back?
    Enforce it mechanically. Record the First Load JS reported by `next build` per route and fail CI when a route exceeds its budget, so any new client-side import path to a heavy package shows up as a failing build rather than as a slow page someone notices months later.

saying these in an interview costs you the question

  • Assumes moving the call site removes the import
  • Trusts tree shaking to drop the unused formatter
  • Only checks the file that was edited
  • Thinks a server-side import cancels a client-side one
  • Judges the fix by feel instead of build output

context