Atomic Design in Next.js: The Import Rule That Does the Real Work
Naming five layers changes nothing. Ordering their imports changes everything — including where the client boundary lands, which on this site was never designed at all.
Atomic design is usually sold as a naming scheme. It is more useful as a dependency rule: components compose strictly upward, a layer may only import from layers below it, and nothing imports sideways. Apply that one constraint and the folder names stop being an argument, because most placement questions collapse into "what is allowed to import this?"
This storefront runs 91 components on that rule across 15 page types. Here is the structure, the two rules that do the actual work, the honest limits of the vocabulary, and what happened when we let one of the rules slip.
The shape
Five layers, and each one is allowed to import only from the layers to its right:
pages (app/) → templates → organisms → molecules → atoms
│ │
└─────────────┴──→ data + design tokens
What that looks like in this repo:
| Layer | Count | Role | Rule |
|---|---|---|---|
| Atoms | 6 | Smallest primitives — a container, a button, a heading, an eyebrow, the logo, a JSON-LD tag | Pure and prop-driven. No data imports, no business content. |
| Molecules | 41 | One reusable unit — a card, a form, a nav item, an accordion row | Content arrives as props. May import atoms. |
| Organisms | 33 | A full page section, owning its own <section> shell | One organism = one section. May import molecules, atoms, and its data file. |
| Templates | 11 | Page layout — orders organisms | Holds no content whatsoever. |
| Pages | 15 routes | Route entry, metadata, structured data | Renders a template. |
Six atoms for a 111-product storefront looks wrong until you notice what an atom is for. Container, Button, SectionLabel, SectionHeading, Logo, JsonLd. That is the complete list of things with no meaning of their own. Everything else — every card, every badge, every pill — carries a shape that belongs to something, which makes it a molecule.
If your atoms folder has fifty files, some of them are molecules wearing the wrong label, and the symptom will be atoms importing data.
Rule 1: composition is strictly upward
The rule is short and the value is in the two clauses people drop.
Never import sideways within a level. One molecule importing another is the first step toward a component that cannot be understood or moved on its own. When two molecules genuinely need the same thing, that thing is an atom, or it is a prop the organism passes to both.
Never import an organism into a molecule. This one sounds too obvious to state until you meet the situation that causes it: a card that needs "just a small version" of a section. The result is a cycle in everything but the literal import graph, and a molecule you cannot render in isolation.
The payoff is not architectural purity. It is that the import direction tells you where to look. A bug in a section is in the organism or one of its molecules — never above, never beside.
Rule 2: molecules never import runtime values from data files
This is the rule that earns its keep, and it is the one worth stealing even if you take nothing else.
A molecule may import a type from a data file. It may not import a value. The organism reads the data, computes what is needed, and passes it down:
// Organism — reads the catalog, derives the label
const frameworks = product.editions
.filter((e) => e.status === "ready")
.map((e) => FRAMEWORK_LABELS[e.framework])
.join(" · ");
return <TemplateCard product={product} frameworksLabel={frameworks} />;
FRAMEWORK_LABELS lives in the catalog module. TemplateCard never touches it. Note that the derivation is a .map() chain rather than a loop that accumulates: organisms build values and pass them down, which is why .map() outnumbers forEach 149 to 4 across this codebase.
It reads like a layering nicety. It is a bundle rule. src/data/catalog.ts is 7,986 lines — every description, changelog entry and screenshot path for 111 products. TemplateCard is a Client Component. A single convenience import of a label map inside it drags that module across the client boundary, and nothing in the type system objects.
We learned this from the version where the rule was not written down. The header's saved-templates side-sheet renders on every page and needed a name and thumbnail per saved slug — a lookup against the catalog. The fix was to stop looking anything up: the wishlist stores the three fields it paints, and the comment on it says why.
/**
* WHY DENORMALIZED ENTRIES (slug + name + image) rather than bare slugs:
* resolving a slug to a name and thumbnail would mean importing
* `src/data/catalog.ts` — a ~7k-line data module — into the global client
* bundle. Storing the three fields the UI actually paints keeps the
* wishlist's client cost near zero.
*/
Same lesson in two places. Leaf components should receive data, not fetch it — and "fetch" includes import.
Where the client boundary actually landed
We never wrote a rule about which layer may be interactive. Two years later the distribution is:
| Layer | Files | "use client" |
|---|---|---|
| Atoms | 6 | 0 |
| Molecules | 41 | 19 |
| Organisms | 33 | 5 |
| Templates | 11 | 0 |
Interactivity lives almost entirely at the molecule layer, and the five client organisms are exactly the ones their names predict: the header, the templates explorer that owns filter state, and three dashboard grids behind auth.
That is not a coincidence, and it is the strongest argument for the structure. A molecule is the natural size of a piece of state — one accordion, one carousel, one form, one picker. When a Client Component keeps growing past that, it is nearly always because a server-shaped concern got dragged in with it, and the layer names make that visible before the bundle analyser does. Which side of that boundary a given piece of work belongs on is a decision with its own trade-offs; the point here is that the folder structure surfaces the question early.
Sections are the unit, and data is a file
The layer that does the most work per line is the organism, and the rule is one organism = one section:
export default function Features() {
return (
<section className="py-16 md:py-24">
<Container>
<SectionLabel>Features</SectionLabel>
<SectionHeading>Everything you need</SectionHeading>
<div className="grid gap-6 md:grid-cols-3">
{features.map((f) => (
<FeatureCard key={f.title} {...f} />
))}
</div>
</Container>
</section>
);
}
The organism owns the <section>, the spacing, the heading, and the map. It does not own the copy — that is in src/data/features.tsx, one file per section, exporting a typed array.
Those files are .tsx rather than .ts because items embed icon and image JSX. The one exception proves the convention: src/data/blog.ts has no JSX in it, so it is .ts.
Splitting content out this way is what makes the adding-a-section procedure boring, which is the goal:
src/data/<name>.tsx— the typed array.- A molecule for the repeated unit.
- An organism composing
Container/SectionLabel/SectionHeadingand mapping the data. - Add the organism to the template.
Four steps, no decisions. A structure that makes routine work routine is doing its job.
The honest limits
Three things about this that are oversold elsewhere.
The molecule/organism line is genuinely ambiguous, and arguing about it is waste. Is a pricing card with its own toggle a molecule or a small organism? Both defensible. Our tie-break is mechanical: does it own a <section> and map over a data array? Organism. Is it one repeated unit receiving props? Molecule. Apply the test, move on, and be willing to move a file later — with the import rule holding, moving one is a mechanical change.
The vocabulary is not the value. If your team says "primitives / components / sections / layouts", nothing here is lost. What matters is that the layers are ordered, imports run one way, and leaves take props. The chemistry metaphor is a mnemonic.
It does not survive being applied without enforcement. Nothing in TypeScript stops a molecule importing an organism, and nothing warns you about the 7,986-line import. Ours is held by review and by convention documented where contributors read it. An import-boundary lint rule is the version that scales, and it is honest to say we have not written one yet.
There is also a real cost to name: more files, and more prop-threading than a component that just reaches for what it needs. On a site of a dozen components, that overhead is not repaid. It starts paying somewhere around the point where you can no longer remember what is in the folder.
Reuse is the discipline, not the goal
The most instructive decision in this codebase is where we chose not to reuse.
Every product page ends with a rail of six sibling templates. TemplateCard already renders that card, so reuse looks obvious. We wrote a second component instead:
/**
* Deliberately not `TemplateCard`: that one is a client component carrying a
* preview modal, a wishlist button, an owner download menu and an ownership
* lookup. Six of them under every product page would ship that interactivity
* for a navigation rail that only needs to be a link. This stays a Server
* Component, so the whole rail costs nothing but markup — which is also what
* makes it crawlable without JavaScript.
*/
Two molecules, similar markup, different jobs. The alternative — one card with a variant="compact" prop — keeps the file count down and ships the modal anyway, because the imports are still in the module.
The question at a reuse site is not "does this component do what I need?" It is "does this component do more than I need, and does the extra travel to the browser?"
Mistakes and how they show up
| Mistake | Symptom | Fix |
|---|---|---|
| Atoms importing data files | "Reusable" primitives that only fit one page | Move the content to props; the atom stops knowing anything |
| A molecule importing a data module for one label | Large module crosses the client boundary silently | Organism computes and passes it down |
| Molecules importing molecules | Components that cannot be rendered in isolation | Promote the shared part to an atom, or pass it as a prop |
| An organism rendered inside a card | Circular reasoning, untestable unit | Extract the shared piece downward |
| Content living in the component | Every copy change is a code review of JSX | One typed data file per section |
| Templates holding copy or business logic | Layout layer becomes a second page layer | Templates order organisms and nothing else |
| One card with five behavioural variants | Every consumer ships every variant's code | Separate components for separate jobs |
| Debating molecule vs organism in review | Time spent, nothing shipped | Apply the mechanical test; move the file later if wrong |
Frequently asked questions
Does atomic design still make sense with Server Components?
More than before. The layers give you a natural place for the client boundary — the molecule that owns the state — instead of scattering "use client" wherever a handler appears. The distribution above is the evidence: it was not designed, and it landed on one layer.
Where do page-level data fetching and metadata go? In the page, above the template. The page reads params, builds metadata and structured data, and hands the template plain props. Templates take data; they do not go looking for it.
Is 91 components a lot for a marketing site? It is 15 route types, 111 product pages, seven category hubs and a blog — so it is a site, not a landing page. The number that matters is not the total but how long it takes to find where a change goes, and the import rule is what keeps that answer short.
How do I retrofit this onto an existing project? Bottom-up, and not all at once. Pull the genuine primitives out first — container, button, headings — then extract content into data files one section at a time. The import rule can be enforced on new code immediately, which stops the problem growing while you work through the rest.
Should I add a lint rule for the layer boundaries? Yes, if the codebase is shared. An import-boundary rule that fails a build on a sideways or downward-facing import turns a review comment into a compiler error, and it is the difference between a convention and a constraint.
Templates built on this structure
Capability grids, feature tiles, pricing tables, testimonial rows — a marketing site is mostly one repeated unit rendered from an array, which is exactly the shape this structure is for. The templates below are built that way: sections composed from typed data files, so changing the copy is editing an array rather than hunting through JSX.
