Skip to content
Chapter 6Lesson 1

The four import-export shapes

Read every ES module import and export as one of four shapes, the vocabulary for understanding your codebase as a graph of connected modules.

You have been writing import lines for five chapters by example, without naming the shapes they take. This chapter treats your codebase as a directed graph of modules, where each import draws an edge from one module to another. This first lesson teaches you to read those edges, a skill the rest of the chapter depends on.

Every import line is one of four shapes: named, default, side-effecting, or dynamic. An experienced engineer reads the shape before the contents, because each shape draws a different kind of edge. Two cross-cutting concerns ride on top of these four. The first is the import type discipline that keeps type-only edges out of runtime code. The second is the bare-specifier rule that decides where a string like 'react' or '@/db' actually points.

The first shape is the one you will write nearly every time. A module exports values by name; an importer pulls those names out by name. The exporter and the importer agree on the spelling.

export const TAX_RATE = 0.08;
export const formatPrice = (cents: number) => `$${(cents / 100).toFixed(2)}`;
export type Money = { cents: number; currency: string };

The matching import:

import { TAX_RATE, formatPrice, type Money } from './pricing';

That import pulls in three values on one line, with identical spellings on both sides. That spelling alignment buys you three things. First, renaming formatPrice to formatAmount breaks every caller at compile time, and your editor’s rename refactor walks every import line and updates them for you. Second, the bundler can drop any named export that no consumer imports, a step called tree-shaking , so an unused helper costs nothing at runtime. Third, the call site reads explicitly: when you see { formatPrice } in an import, you know exactly which symbol you are pulling in.

Every utility, every component, and every type you author in this course is a named export. There is no exception to learn here; the cases that call for a different shape all belong to the next section.

Default exports: only when the framework demands them

Section titled “Default exports: only when the framework demands them”

The second shape exists, but you will rarely choose to author it yourself. A module marks one export as its default, and an importer picks any name it wants for that default.

export default function Page() {
return <h1>Invoices</h1>;
}
import Page from './page';

Notice that Page on the import side is a name the caller chose. The exporter did not declare Page; it declared “the default.” If you renamed the function in ./page to InvoicesIndex, every import line in the rest of the codebase would keep saying import Page from './page' and keep compiling. The rename does not propagate, because there is no shared name to propagate.

That asymmetry is the reason named exports are the rule and default exports are the exception. The exceptions in 2026 are few, and the framework owns all of them:

  • Next.js App Router special files: page.tsx, layout.tsx, loading.tsx, error.tsx, not-found.tsx, template.tsx, default.tsx.
  • A handful of third-party libraries whose primary export ships as default, React itself being one example.
  • Everywhere else: named, not default.

When you write a page.tsx, you write export default. Everywhere else in the codebase you write export const or export function. The rule is straightforward: follow the framework where it demands a default, and reach for named in every file you own.

Side-effecting imports: when the file runs for what it does, not what it returns

Section titled “Side-effecting imports: when the file runs for what it does, not what it returns”

The third shape looks almost wrong the first time you see it. There is no binding on the left of from: no { ... }, no default name. The importer asks the runtime to evaluate the file, then moves on.

import 'server-only';
import './globals.css';

The first line registers a build-time rule that no client bundle can ever reach this module; the next lesson covers what server-only enforces. The second line tells Next.js to include the global stylesheet in the page’s CSS bundle. Neither line produces a value the importer reads. The point of the statement is the side effect of evaluating the file.

In 2026 SaaS code, side-effecting imports are rare and deliberate. The two canonical uses are the two above: a 'server-only' or 'client-only' guard at the top of a boundary file, and a global CSS import in app/layout.tsx. Anywhere else, a bare import 'something' is a warning sign, because it usually means the file does something at the top level that the consumer cannot see from the import line. When you meet one in code review, ask about it rather than pattern-matching past it.

Dynamic imports: a value-level expression that returns a Promise

Section titled “Dynamic imports: a value-level expression that returns a Promise”

The fourth shape is structurally different from the first three. Those three are statements that live at the top level of a module, and they declare a binding before any code runs. A dynamic import is an expression: it can appear anywhere a value can appear, and it returns a Promise<Module> that the runtime resolves when the expression evaluates.

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

The import('./chart') syntax looks like a function call, and for the purpose of reading the code you can treat it as one. It returns a promise, you await it, and you destructure the module’s exports off the resolved value. The edge into the module graph is still real, since the runtime fetches and evaluates ./chart when the expression runs, but the edge is deferred. The bundler treats it specially: it emits ./chart, and everything ./chart imports, as a separate chunk that is fetched on demand the first time the click handler runs. The next lesson covers this code-splitting in full.

The four shapes are the spine of every import line in the codebase. The rest of this lesson covers two cross-cutting concerns that ride on top of those four, then two structural rules, re-exports and bare-specifier resolution, that decide what gets imported and from where.

The first cross-cutting concern is one you have already met. Back in the “Annotate the boundaries, infer the inside” lesson, the course turned on verbatimModuleSyntax , the TypeScript flag that requires every type-only import to carry the type keyword. The flag stays on for the rest of the course, so reading the type keyword on an import is a skill you will use constantly.

There are two forms you will write. Decide between them by asking whether the file needs the value at runtime, or only the type at compile time.

import type { User } from './users';
declare function getUser(id: string): Promise<User>;

When a file uses a symbol only as a type, whether in a parameter annotation, a return type, or a type alias, reach for import type. The whole statement is erased at compile time, so no edge into the module graph survives into the runtime. This is the default reach when the file genuinely does not need the value.

The same type keyword works on re-exports too:

export type { User } from './users';

That line forwards the User type to consumers of the current module without drawing any runtime edge. The discipline is the same as for imports.

This discipline matters because of a class of silent bug that verbatimModuleSyntax prevents. The flag enforces a strict rule: if an import is a type, mark it with type; otherwise the compiler keeps the statement in the emitted code. That rule closes a gap that shows up the moment a module has side effects, such as a console.log, a global registration, or a 'server-only' guard. Consider what happens without the flag. If you write import { Logger } from './logger' and use Logger only as a type, the compiler is free to erase the entire import statement, which silently drops the logger’s initialization. The bug would not surface until production, when the logs go missing. verbatimModuleSyntax removes that ambiguity at the source.

Re-exports: the transparent forwarding form

Section titled “Re-exports: the transparent forwarding form”

A module can pull a binding from one module and immediately republish it under its own name. The shape:

export { createInvoice } from './actions';
export type { Invoice } from './types';
// wildcard form — used sparingly
export * from './schemas';

Each export ... from line draws a real edge into the named module, so that module is still evaluated. The line then makes the listed bindings available from the re-exporter. This is the right reach when a domain module wants to expose a curated surface. For example, db/queries/invoices.ts might re-export the four read helpers callers actually use while hiding the fifteen internal building blocks that compose them.

Re-exports become a problem in the barrel file pattern: an index.ts that re-exports dozens of unrelated symbols from across a directory. Barrels hurt tree-shaking, because some bundlers cannot tell which downstream re-exports are unused, and they degrade editor go-to-definition, because the IDE walks through the barrel before reaching the real source. The course’s code conventions are explicit: do not author barrels in lib/, db/, or _lib/. Reach for export * only when the surface is intentionally open, such as a domain module that wants to expose its full public API in one statement.

One mechanical rule is worth knowing: export * from two modules that share a symbol name is a compile error. The compiler refuses the ambiguity rather than silently picking one. The wildcard form trades that safety for terseness, so reach for it only when you trust the named modules not to collide.

Where does 'pkg' actually come from? Bare-specifier resolution

Section titled “Where does 'pkg' actually come from? Bare-specifier resolution”

The second cross-cutting concern is a question every import line quietly answers: where does that string on the right of from actually point? When the specifier starts with ./ or ../, the answer is “relative to this file,” which is straightforward. But the moment the specifier has no leading dot, it becomes a bare specifier , and the resolution rule changes. 'react', 'next/headers', and '@/db' are all bare. Where do they resolve from?

The answer is one of three rules, and the diagram below shows all three side by side.

import { useState } from 'react'
node_modules/react/package.json
-> exports field
import { cookies } from 'next/headers'
node_modules/next/package.json
-> exports['./headers']
import { db } from '@/db'
tsconfig.json
-> paths['@/*'] -> on-disk file
Three bare-specifier shapes; three resolution rules. The shape on the left tells you which rule on the right will fire.

Three different specifiers resolve through three different rules. Take them in turn.

The first row is a package name, 'react', with no slash. The runtime walks up the directory tree from the importing file looking for a node_modules/react folder. When it finds one, it reads react/package.json and looks at the exports field , a map declaring which files the package makes importable under which specifiers. In 2026, the exports field is the source of truth for what a package exposes, and a deep import it does not list will fail with ERR_PACKAGE_PATH_NOT_EXPORTED even if the file exists on disk. That refusal is intentional: it is how libraries communicate their public surface. Older packages without an exports field fall back to the legacy main field.

The second row is a subpath inside a package, 'next/headers', where everything after the first slash is a key the package’s exports field must declare. Next.js ships "./headers" as a recognized subpath, and the field points to the implementation file. The takeaway is the same as the first row, only sharper: the slash does not give you free access to any file in the package. If the exports field does not list "./internal/something", then import { x } from 'next/internal/something' does not resolve, even if you can see the file in node_modules/next/internal/something.js. The subpaths a SaaS engineer reaches for daily are a small set: next/cache, next/headers, next/navigation, next/server, and zod/v4.

The third row is a TypeScript path alias , '@/db', with the leading @/. This is not a node_modules lookup at all. The TypeScript compiler reads the paths field of tsconfig.json, the same field you set up back in the “Run TypeScript locally” lesson, sees "@/*": ["src/*"], and resolves '@/db' to src/db/index.ts on disk. The bundler reads the same tsconfig.json and agrees on the resolution. You will see this alias on nearly every server file in the course, because it lets you write the same path from anywhere in the codebase without a chain of ../../../.

There is one last shape that does not quite fit the four-form taxonomy but that you will see in 2026 code: a JSON file imported as a module.

import config from './config.json' with { type: 'json' };

The with { type: 'json' } attribute is mandatory in modern Node and required by the V8 runtime for security. The runtime validates the file’s MIME type before parsing it as JSON, which prevents it from evaluating a misnamed JavaScript file as data. The binding form is always default, because JSON modules expose only a default export; a parsed JSON object has no named exports. The shape is fixed.

One thing to watch: this works only in static positions, at the top of a module. The runtime materializes the JSON before the module evaluates, so you cannot construct the path dynamically. For a dynamic JSON import, the attribute moves to the second argument of import():

const { default: config } = await import('./config.json', { with: { type: 'json' } });

This is the shape for the rare case where you load a JSON file conditionally at runtime. Parsing JSON at the wire boundary, where you fetch it over HTTP and validate its shape with Zod before you trust it, lives in a later chapter on JSON parsing. The form here is for static JSON files bundled into your code.

Below are twelve import statements. Drop each into the shape it represents. Most are unambiguous, but two of them are the recognition traps the lesson body set up.

Each chip is one import or export line you might encounter in a 2026 codebase. Drop each into the shape it represents. Drag each item into the bucket it belongs to, then press Check.

Named import or export The course default — value or type bindings pulled by spelling.
Default import or export Framework-mandated only; the importer picks the name.
Side-effecting import No binding — the file runs for its side effects.
Dynamic import A value-level expression that returns Promise<Module>.
Type-only import or export Erased at compile time; draws no runtime edge.
Re-export Forwards a binding from one module through another.
import { Button } from './ui/button'
import 'server-only'
import Page from './page'
import type { User } from '@/db'
const m = await import('./heavy-chart')
import config from './config.json' with { type: 'json' }
import { cookies } from 'next/headers'
export { createInvoice } from './actions'
import { createUser, type User } from './users'
import './globals.css'
import 'client-only'
import { sql } from 'drizzle-orm'

The two trap items, import config from './config.json' with { type: 'json' } and import { createUser, type User } from './users', test the recognition skills the lesson body taught. The JSON with attribute does not change the fact that the binding form is a default import. The inline type modifier does not change the fact that the overall statement is still a named import. If both of those landed in the right bucket, your shape-reading is solid.