open atlas
↑ Back to track
Next.js, zero to senior NEXT · 08 · 01

Route groups as module boundaries: per-domain shells, colocation, and the full-reload tax

Route groups partition app/ into domain modules that vanish from the URL: per-group root layouts give marketing and app separate shells at the cost of a full reload when crossing. Private folders keep code colocated; lint, not the router, enforces dependency direction.

NEXT Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

A B2B SaaS at 40 engineers, one Next.js app, 214 route folders sitting flat in app/. The incident that finally forces the conversation is small: marketing asks an agency to add an analytics script and two display fonts. The agency engineer puts them where everything global goes — the root layout — and ships. The script and fonts now load on every authenticated dashboard view too; dashboard LCP regresses by 600 ms, and the on-call engineer spends half a day bisecting deploys because nothing in the dashboard code changed. The retro surfaces the deeper rot: app/components/ is a 200-file junk drawer that every PR touches, two squads maintain duplicate Modal implementations because neither could find the other one, and a new hire took three days to locate where billing settings render. The team splits the app into route groups with separate marketing and app shells — and immediately hits the tax nobody warned them about: crossing from /pricing to /login now does a full page reload. The boundary is right; this lesson is about knowing where it costs.

The URL is not your module system

The app router maps folders to URL segments, which quietly couples your code organization to your public information architecture: if /pricing and /dashboard/billing must live at those URLs, their code apparently must live at those paths. Route groups break the coupling. A folder named in parentheses — (marketing), (app), (admin) — is stripped from the URL entirely: app/(marketing)/pricing/page.tsx still serves /pricing. That makes groups pure organization, which is exactly what a module boundary needs to be: you can carve a 214-folder flat namespace into domains owned by squads — one CODEOWNERS line per group — without a single redirect, and refactor incrementally because the URL space never notices. Each group can carry its own layout.tsx, loading.tsx, and error.tsx, so a group is not just a folder: it is a scope for the rendering shell, the suspense boundary, and the error boundary of one domain.

Per-group root layouts and the reload tax

The strongest version of the boundary is deleting the top-level app/layout.tsx and giving each group its own root layout — each one rendering the html and body tags itself. Now marketing gets a static shell: fonts, the analytics script, zero client providers. The app group gets the session provider, sidebar chrome, and theme context. In the SaaS above, moving providers and the analytics bundle out of the marketing shell took landing-page first-load JS from 212 kB to 89 kB — the marketing pages stopped paying for a dashboard they never render.

The price is structural, not incidental: navigating across root layouts cannot be a client-side transition. The router patches the part of the tree below the shared layout, and when even the html element differs there is nothing shared to patch — Next falls back to a full document navigation. Every piece of client state dies with the document: context values, in-memory caches, an open toast, a half-typed form living in a provider. The transition costs a network round-trip plus full hydration — roughly 0.5–1.5 s on mid-tier mobile against ~100 ms for a client navigation. The design rule falls out directly: cut root layouts only along seams users rarely cross mid-session. Marketing to app is crossed once, at login — a fine place to pay a reload. Dashboard to settings is crossed forty times a day — if that pair lands in different root layouts, the group cut is wrong.

Quiz

A team deletes app/layout.tsx and gives (marketing) and (app) their own root layouts. A user on /pricing clicks a Link to /login, which lives in (app). What actually happens?

Colocation, private folders, and earning promotion

Only page.tsx and route.ts create URLs, so colocating non-route files next to the routes that use them is safe by default. The convention worth enforcing is the underscore: _components/, _lib/ — private folders that are opted out of routing entirely, a loud signal that everything inside belongs to this route and nothing else. The discipline has two halves. First: code lives next to the only route that uses it — a checkout-only price formatter lives in (checkout)/_lib/, not in a global utils file. Second: shared code must earn promotion out. A reasonable bar: used by two or more domains, has a named owner, has its own tests — then it moves to shared/ (or, later, a workspace package). The payoff is an invariant senior teams prize: deleting a route folder deletes its UI, dead code cannot hide. The failure mode of skipping this is the one from the hook — app/components/ as a 200-file commons where every PR conflicts, nothing can be deleted because nobody knows who imports it, and duplicate components breed because search returns 40 candidates.

Conventions do not compile — lint the direction

Here is the mechanism honesty that the docs will not volunteer: route groups and private folders change zero module-resolution semantics. Any file can import any other file across groups; TypeScript resolves it, the bundler ships it, CI stays green. The architecture diagram where (billing) never reaches into (admin) is a wish until a tool fails the build. The standard tool is an ESLint boundary rule:

// eslint.config.js — import/no-restricted-paths zones
{
  rules: {
    'import/no-restricted-paths': ['error', {
      zones: [
        // domains may not import each other — only shared/
        { target: './app/(app)', from: './app/(marketing)' },
        { target: './app/(marketing)', from: './app/(app)' },
        { target: './app/(admin)', from: './app/(app)' },
        // shared/ may not import upward into any domain
        { target: './src/shared', from: './app' },
      ],
    }],
  },
}

Groups import from shared/; never sideways into each other; shared/ imports from no group — dependencies point one way, downward. With the zones in CI, a sideways import is a red build instead of a review comment someone forgets to leave. This is the only form of architecture that survives 40 engineers shipping in parallel.

Quiz

What stops code in (billing) from importing a pricing helper out of (admin)/_lib?

Parallel routes: composition for dashboards

One more tool belongs in the modularity kit: parallel routes. A layout can declare slots — @revenue, @alerts, @activity — and receive each as a prop, rendering them side by side; each slot folder carries its own loading.tsx and error.tsx, plus a default.tsx for when the slot has no match. That gives a dashboard exactly what a composed screen of independently owned panels needs: the revenue squad’s panel can stream late or fail without touching the alerts panel, and each squad owns its slot folder outright. The honest scoping: slots add real file-system complexity and subtle soft-navigation behavior — when you reach for them, ask whether the panels genuinely have independent data and error lifecycles, not just independent visual placement.

Recall before you leave
  1. 01
    What does a route group change, what does it leave untouched, and what is the price of per-group root layouts?
  2. 02
    State the colocation and promotion discipline, and explain how dependency direction is actually enforced.
Recap

The app router couples folders to URLs until route groups uncouple them: a parenthesized folder vanishes from the URL while scoping layouts, loading and error boundaries, and ownership — which makes it the natural module boundary inside one Next.js app, refactorable incrementally because the public URL space never moves. The strongest cut gives each domain its own root layout rendering html and body itself: marketing becomes a static shell with fonts and analytics and zero client providers (212 kB to 89 kB first-load JS in the running example) while the app group keeps session and theme context. The tax is non-negotiable — crossing root layouts cannot be patched by the client router, so it is a full document navigation that drops every piece of client state and costs 0.5–1.5 s instead of ~100 ms — which dictates the placement rule: separate root layouts only along seams users cross about once per session, like login. Inside a group, colocation is the discipline: _components and _lib opt out of routing, code lives with its only consumer, and shared code earns promotion by being used in two or more domains with an owner and tests, preserving the invariant that deleting a route deletes its UI. None of this is enforced by the router — groups change zero module-resolution semantics, so the dependency direction (groups import shared, never sideways; shared imports no group) holds only when an ESLint zones rule fails CI on violations. Parallel routes finish the kit: @slots compose a dashboard from independently owned panels with independent loading and error lifecycles. Now when you open a PR and see an import crossing from (billing) into (admin), you know that is not a style issue — it is a missing lint zone. And when a product manager asks why navigating from /pricing to /login feels slow, you know exactly which seam was cut in the wrong place.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.