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 treating your codebase as a graph.

You have written import lines for five chapters without naming the shapes they take. This chapter treats your codebase as a directed graph of modules, where every import is an edge between two modules. This first lesson teaches you to read those edges.

Every import line is one of four shapes, each a different kind of edge: named, default, side-effecting, or dynamic. Two concerns cut across all four. The first, import type, keeps type-only edges out of runtime code. The second, the bare-specifier rule, decides where a string like 'react' or '@/db' points.

The first shape is the one you will write nearly every time. A module exports values by name; an importer pulls them out by the same name on both sides.

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 shared spelling buys three things. First, renaming formatPrice breaks every caller at compile time, and your editor’s rename refactor updates every import for you. Second, the bundler drops any named export no consumer imports, a step called tree-shaking , so an unused helper costs nothing at runtime. Third, the call site is explicit: { formatPrice } names exactly the symbol you pull in.

Every utility, component, and type you author in this course is a named export.

Default exports: only when the framework demands them

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

The second shape you will rarely author yourself. A module marks one export as its default, and the importer picks any name for it.

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

Page on the import side is the caller’s choice; the exporter declared only “the default,” never Page. Rename the function in ./page to InvoicesIndex and every import Page from './page' keeps compiling: with no shared name, the rename has nothing to propagate to.

That asymmetry is why named exports are the rule and default exports the exception. The exceptions 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 among them.
  • Everywhere else: named, not default.

Side-effecting imports: the file runs for its side effect

Section titled “Side-effecting imports: the file runs for its side effect”

The third shape looks wrong: no binding left of from, no { ... }, no default name. The importer asks the runtime to evaluate the file, nothing more.

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

The first line registers a build-time rule that no client bundle can reach this module; the next lesson covers what server-only enforces. The second tells Next.js to bundle the global stylesheet into the page’s CSS. Neither yields a value the importer reads; the statement exists for the side effect of running the file.

Side-effecting imports are rare and deliberate. The two canonical uses are above: a 'server-only' or 'client-only' guard atop a boundary file, and a global CSS import in app/layout.tsx. Anywhere else, a bare import 'something' means the file acts at the top level in a way the import line hides, so raise it in code review.

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

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

The first three shapes are statements: they sit at the top of a module and declare a binding before any code runs. A dynamic import is an expression, so it can appear anywhere a value can, and it returns a Promise<Module> that resolves when the expression evaluates.

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

The import('./chart') syntax looks like a function call, and you can read 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 real, since the runtime fetches and evaluates ./chart when the expression runs, but deferred: the bundler emits ./chart, and everything it imports, as a separate chunk fetched on demand the first time the 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 the two concerns that cut across them, then two structural rules, re-exports and bare-specifier resolution, that decide what gets imported and from where.

The “Annotate vs. infer types” lesson turned on verbatimModuleSyntax . You write one of two forms, depending on whether the file needs the value at runtime or only the type.

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

When a file uses a symbol only as a type, reach for import type. The whole statement is erased at compile time, leaving no runtime edge into the module graph.

The same keyword works on re-exports, forwarding a type without a runtime edge:

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

The flag guards against a silent bug. Without it, an import { Logger } from './logger' used only as a type can be erased, taking the logger’s top-level side effects with it: a console.log, a global registration, a 'server-only' guard. The drop surfaces only in production, when the logs go missing. verbatimModuleSyntax forces a type import to carry type, so the compiler keeps every other import in the emitted code.

A module can pull a binding from another module and 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, evaluating it, and makes the listed bindings available from the re-exporter. This is how a domain module exposes a curated surface: db/queries/invoices.ts re-exports the read helpers callers actually use and hides the internal building blocks behind them.

Re-exports turn bad in the barrel file pattern: an index.ts that re-exports dozens of unrelated symbols from across a directory. Barrels hurt tree-shaking, since some bundlers cannot tell which re-exports go unused, and they degrade go-to-definition, since the IDE walks the barrel before reaching the real source. The course bans them in lib/, db/, and _lib/. Reach for export * only when the surface is intentionally open.

Re-exporting two modules that share a symbol name is a compile error: the compiler refuses the ambiguity rather than silently picking one, so use the wildcard form only when you trust the named modules not to collide.

Bare-specifier resolution: where 'pkg' resolves from

Section titled “Bare-specifier resolution: where 'pkg' resolves from”

Every import resolves the string after from. A ./ or ../ specifier resolves relative to the current file. Drop the leading dot and it becomes a bare specifier , resolved by one of three rules. 'react', 'next/headers', and '@/db' are all bare.

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.

The first row is a package name, 'react'. The runtime walks up from the importing file until it finds node_modules/react, then reads its package.json and the exports field . A deep import the field does not list fails with ERR_PACKAGE_PATH_NOT_EXPORTED even when the file exists. This is how a library declares its public surface.

The second row is a subpath inside a package, 'next/headers', where everything after the slash must be a key in the package’s exports field. Next.js declares "./headers"; an undeclared subpath like 'next/internal/something' fails even with the file at node_modules/next/internal/something.js. The subpaths a web app uses daily are few: next/cache, next/headers, next/navigation, next/server, and zod/v4.

The third row is a TypeScript path alias , '@/db'. This is no node_modules lookup: the compiler reads the paths field of tsconfig.json, sees "@/*": ["src/*"], and resolves '@/db' to src/db/index.ts. The bundler reads the same tsconfig.json and agrees. You will see this alias on nearly every server file, because it names one path from anywhere without a chain of ../../../.

One last shape sits outside the four-form taxonomy: a JSON file imported as a module.

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

The with { type: 'json' } attribute is mandatory: the runtime checks the file type before parsing, so it never runs a misnamed JavaScript file as data. The binding is always a default import, because parsed JSON has no named exports.

This works only in a static position at the top of a module: the JSON loads before the module evaluates, so the path cannot be dynamic. To import JSON dynamically, move the attribute to the second argument of import():

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

The static form is for JSON bundled into your code.

Twelve import statements follow; two are traps.

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'

import config from './config.json' with { type: 'json' } is a default import; the with attribute only tells the runtime how to parse the file. import { createUser, type User } from './users' is a named import; the inline type marks one binding as type-only without changing the shape.