Skip to content
Chapter 6Lesson 2

How modules evaluate and reach the browser

How the JavaScript runtime evaluates your import graph, and how dynamic `import()` and the `'use client'`/`'server-only'` directives decide what ships to the browser.

Suppose one file, lib/utils.ts, holds both a pure date formatter and a getCurrentUser that reads cookies and queries the database. A client component imports only the formatter. The browser still downloads 800KB of database driver, code the user will never run, because the bundler walked from that component through every reachable import.

The previous lesson framed each import as an edge in a directed graph. This lesson is about what the runtime and bundler do with that graph: how they evaluate it, what an import actually points at, and which edges decide what reaches the browser.

How the runtime evaluates the graph: depth-first, once per module

Section titled “How the runtime evaluates the graph: depth-first, once per module”

A console.log at the top of a leaf module runs before one at the top of the file that imported it, and the evaluation order is why.

The runtime starts at your entry module, follows each import down to its target, and recurses into that target’s imports before running any of the importer’s own top-level code. A module evaluates only after all of its imports have finished. Leaves run first, the root runs last.

flowchart LR
  page["⑥ page.tsx"]
  auth["③ auth.ts"]
  format["⑤ format.ts"]
  db["② db.ts"]
  env["① env.ts"]
  temporal["④ temporal.ts"]

  page --> auth
  page --> format
  auth --> db
  db --> env
  format --> temporal
The runtime walks from `page.tsx` depth-first; numbered badges mark the evaluation order. Leaves finish before their importers, so `env.ts` runs first and `page.tsx` runs last. Each module runs exactly once.

Three rules follow.

Depth-first, post-order. The runtime finishes a module’s children before the module itself, so no top-level statement in auth.ts can run until db.ts has finished and its exports exist.

Once per module. A file imported from two places does not run twice: the runtime caches each module by its resolved path, so the second import reuses the first evaluation and every top-level const is shared across all consumers. This is why the next lesson’s module-level singleton works, a single let cached = null at module scope is one slot for the whole app.

Errors short-circuit upward. A throw at the top of a module stops every module above it from finishing: if env.ts throws on a missing environment variable, db.ts never loads, auth.ts never runs, and the page never renders. That is the mechanism behind fail-closed startup validation.

Imports are live bindings, not value copies

Section titled “Imports are live bindings, not value copies”

An import is not a copy of the exported value. It is a read-only window onto the exporter’s variable: when the exporter mutates that variable, the importer sees the new value.

counter.ts
export let count = 0;
export const increment = () => {
count += 1;
};
consumer.ts
import { count, increment } from './counter';
console.log(count); // 0
increment();
console.log(count); // 1 — the import tracks the exporter

On the green-marked lines, increment() writes the exporter’s slot and the consumer’s next read sees 1: count is a binding to counter.ts’s variable, not a snapshot of it.

Two consequences follow.

Re-exports preserve the live binding. export { count } from './counter' re-exposes the binding rather than snapshotting it, so a consumer importing count through three layers of re-export still tracks the original variable in counter.ts.

The importer cannot reassign the binding. Inside the consumer, count = 5 is a compile-time error: only the exporter writes, everyone else reads.

CommonJS require() differs: it reads module.exports once and copies it, so older Node code does not behave this way.

Circular dependencies: when modules import each other

Section titled “Circular dependencies: when modules import each other”

A cycle is an import graph that loops back on itself. Some cycles crash; others resolve cleanly, and the difference is predictable.

A cycle crashes when one module reads another’s export at the top level before that export has been assigned.

a.ts
import { fromB } from './b';
export const fromA = fromB + 1; // fromB is undefined when this runs
b.ts
import { fromA } from './a';
export const fromB = fromA + 1;

An entry module imports a.ts, which starts evaluating and immediately imports b.ts. b.ts imports back into a.ts, but a.ts is mid-evaluation and fromA is not assigned yet, so the runtime hands b.ts the partial module. b.ts reads fromA as undefined, and the arithmetic coerces it to NaN. The cycle is not the error; the bug is the top-level read of a value that was not ready.

Function-body access. If b.ts reads fromA only inside a function body, the cycle is harmless. By the time anyone calls that function, both modules have finished evaluating and the live binding points at a real value.

Type-only cycles. import type is erased at compile time, so a type-level cycle exists only inside the type checker and never reaches the runtime. When two modules need each other’s types, converting one import to import type dissolves the cycle.

Extract the shared module to break the cycle

Section titled “Extract the shared module to break the cycle”

The fix for a value-level cycle is to pull the shared symbol into a third module, so neither a.ts nor b.ts imports the other. The cycle becomes a Y-shape: both depend on shared.ts, the runtime evaluates shared.ts first, then a.ts and b.ts in either order.

  • Directorysrc/
    • entry.ts
    • a.ts imports fromB from b.ts
    • b.ts imports fromA from a.ts

Whichever file evaluates first hands the other a partial view, so top-level reads return undefined.

This Y-shape returns in Drizzle’s relations API, where one shared file holds the relation declarations both tables reference.

Deferred edges: dynamic import() and code splitting

Section titled “Deferred edges: dynamic import() and code splitting”

The previous lesson introduced import(), the function-call form returning a Promise<Module>. Here is what it does to the bundle.

A static import draws an eager edge: the target ships in the same chunk as the importer, part of the initial JavaScript download.

import { renderChart } from './heavy-chart';

If heavy-chart.ts and its dependencies weigh 200KB, the browser parses that 200KB on first page load, whether the user ever opens the chart or not.

import() as an expression draws a deferred edge: the bundler emits the target as a separate chunk, fetched only when the expression runs. The cost moves. You pay one network round-trip the first time the code path runs, in exchange for keeping those bytes out of the initial download.

const onAnalyticsTabClick = async () => {
const { renderChart } = await import('./heavy-chart');
renderChart();
};

Now heavy-chart’s 200KB sits in its own file on the CDN. The first click on the analytics tab fetches the chunk; later clicks reuse the cached copy. This is code splitting , triggered by the dynamic import() expression.

The await did not create the chunk. The bundler splits on the import() form it sees at build time, so an await in front of a static import splits nothing.

  • Heavy and rarely used. A chart library in a settings page, a markdown editor in an admin tool: real bytes most sessions never reach.
  • Conditional. A locale-specific date module loaded for the user’s locale. A static import forces every locale into every bundle; the dynamic one loads just the needed one.
  • Not for page-to-page splits. App Router route segments split automatically, so the framework already emits per-route chunks. Reach for dynamic import() to split a component or feature flag inside a page, not the navigation graph.

When the dynamic target is a React component, Next.js ships next/dynamic, a wrapper that pairs import() with Suspense and adds SSR controls. The one you reach for most is ssr: false, which skips server rendering for a component that touches window, localStorage, or another browser-only API. Unit 4 covers it in depth; for now, read it as the React-aware shape of the same import() idea.

The bundle boundary: 'use client', 'server-only', 'client-only'

Section titled “The bundle boundary: 'use client', 'server-only', 'client-only'”

The graph also decides which modules ship to the browser. Three directives mark that boundary, each with one job.

'use client' marks an entry into the client bundle

Section titled “'use client' marks an entry into the client bundle”

A file beginning with 'use client'; is a client entry point: from it the bundler crawls every static import, and every module it reaches ships to the browser, so every one must be safe to run there. Server Components, the App Router default, need no directive.

Place the directive on the smallest interactive leaf, the actual button, form, or piece of state that needs the browser. On a parent layout it drags the whole subtree into the client bundle, including any server-only helper that subtree imports. Unit 4 covers this in depth.

'use client';
import { useState } from 'react';
export const Counter = () => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
};

import 'server-only' makes leaks a build error

Section titled “import 'server-only' makes leaks a build error”

A server-only module is anything that touches secrets, the database, request cookies, request headers, or an SDK initialized with a private key. Mark it with import 'server-only'; as its first line.

import 'server-only';
import { db } from '@/db';
export const getCurrentUser = async () => {
// reads the session cookie and queries the database
};

The package does nothing at runtime; its job is at build time. If a client bundle ever reaches the file, the build fails with an error naming the offending import chain, making accidental leaks structurally impossible rather than merely discouraged. This runs throughout the course’s codebase: env.ts, the Drizzle client, the Better Auth instance, and every billing, email, and storage adapter starts with it.

The symmetric package marks modules that touch window, localStorage, or another browser-only API and would crash on the server. Most such code already lives inside a 'use client' file, where it is safe by construction, so you reach for 'client-only' less often: it fits a standalone utility, like a window.matchMedia wrapper, that should refuse to be imported from a Server Component.

Splitting a mixed module so server code stays off the client

Section titled “Splitting a mixed module so server code stays off the client”

This is the fix for the 800KB story from the opening: when one file holds both a pure helper and server-side code, importing the helper from a client component drags the server code with it. Split the file by responsibility and let 'server-only' enforce the cut.

lib/utils.ts
import { headers } from 'next/headers';
import { db } from '@/db';
export const formatDate = (d: Date) => d.toISOString().slice(0, 10);
export const getCurrentUser = async () => {
await headers();
// db query reading the session cookie...
return db.query.users.findFirst(/* simplified for the example */);
};

A client component imports only formatDate, but the bundler walks every reachable edge, so the red-marked server seams, db, headers, and the Drizzle relations graph, ship to the browser too.

The rule to carry forward: pure utilities live in their own files, and anything that touches a server seam imports 'server-only'.

Four snippets, one per core idea. Predict each outcome before reading the explanation.

Given these two files:

counter.ts
export let count = 0;
export const increment = () => {
count += 1;
};
consumer.ts
import { count, increment } from './counter';
console.log(count);
increment();
console.log(count);

What does consumer.ts log?

Logs 0 then 1
Logs 0 then 0
TypeError on the second log

A value-level import cycle converted to type-only imports:

a.ts
import type { TypeB } from './b';
export type TypeA = { b: TypeB };
export const valueA = 42;
b.ts
import type { TypeA } from './a';
export type TypeB = { a: TypeA };
export const valueB = 7;

A consumer imports valueA from a.ts and logs it. What happens?

Logs 42 — no runtime cycle exists
Crashes at runtime
Build error

Given these two files:

app/_components/dashboard.tsx
'use client';
import { getCurrentUser } from '@/lib/auth';
export const Dashboard = () => {
// ...
};
lib/auth.ts
import 'server-only';
import { db } from '@/db';
export const getCurrentUser = async () => {
// reads session, queries db
};

What happens when you build?

Build error naming the import chain that leaked
Builds and runs — server-only is just a documentation hint
Builds, then crashes at runtime in the browser

Given this client component:

app/_components/analytics-tab.tsx
'use client';
export const AnalyticsTab = () => {
const onClick = async () => {
const { renderChart } = await import('./heavy-chart');
renderChart();
};
return <button onClick={onClick}>Open analytics</button>;
};

Does heavy-chart’s code ship in the initial bundle?

No — the bundler emits it as a separate chunk fetched when the click handler runs
Yes — await import is just an await in front of a regular import
Only if the user has JavaScript enabled