Skip to main content
ASoc
Guide

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.

The ASoc Team12 min read

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:

LayerCountRoleRule
Atoms6Smallest primitives — a container, a button, a heading, an eyebrow, the logo, a JSON-LD tagPure and prop-driven. No data imports, no business content.
Molecules41One reusable unit — a card, a form, a nav item, an accordion rowContent arrives as props. May import atoms.
Organisms33A full page section, owning its own <section> shellOne organism = one section. May import molecules, atoms, and its data file.
Templates11Page layout — orders organismsHolds no content whatsoever.
Pages15 routesRoute entry, metadata, structured dataRenders 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:

LayerFiles"use client"
Atoms60
Molecules4119
Organisms335
Templates110

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:

  1. src/data/<name>.tsx — the typed array.
  2. A molecule for the repeated unit.
  3. An organism composing Container / SectionLabel / SectionHeading and mapping the data.
  4. 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

MistakeSymptomFix
Atoms importing data files"Reusable" primitives that only fit one pageMove the content to props; the atom stops knowing anything
A molecule importing a data module for one labelLarge module crosses the client boundary silentlyOrganism computes and passes it down
Molecules importing moleculesComponents that cannot be rendered in isolationPromote the shared part to an atom, or pass it as a prop
An organism rendered inside a cardCircular reasoning, untestable unitExtract the shared piece downward
Content living in the componentEvery copy change is a code review of JSXOne typed data file per section
Templates holding copy or business logicLayout layer becomes a second page layerTemplates order organisms and nothing else
One card with five behavioural variantsEvery consumer ships every variant's codeSeparate components for separate jobs
Debating molecule vs organism in reviewTime spent, nothing shippedApply 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.

Keep reading

Guide13 min read

A Digital Product Refund Policy You Can Enforce in Code

A 14-day, no-download refund policy and the four-state engine behind it — including the attribution 'fix' that opens a download-then-refund bypass.

Read more
Guide12 min read

Ecommerce Site Cost: Four Decisions, Not a Quote

Agency guides price the build. The recurring bill is set by four architectural choices — and here they are itemised from a live storefront: 111 products, 288 static pages, 13 dependencies.

Read more
Guide9 min read

Firebase Hosting Cost, Measured Against a Real 156 MB Build

Firebase bills $0.026/GB stored and $0.15/GB transferred. This storefront's 156.5 MB build uses 1.6% of the free storage tier — transfer is the line that bills.

Read more