(marketing) Is Not in the URL: What Parentheses Remove and What They Keep
A URL has to stay stable while your repository does not. The marketing site, the blog, and the logged-in dashboard are three products in one Next.js app, and cramming them into a flat app/ directory produces names like pricing-draft/page.tsx that eventually leak into a real link. Parenthesized folders solve this by being organizational only: a folder named (marketing) groups its children and is stripped from the URL when the route is built.
app/
(marketing)/
layout.tsx -> wraps everything in this group
page.tsx -> / (not /%28marketing%29)
pricing/page.tsx -> /pricing
about/page.tsx -> /about
(app)/
dashboard/page.tsx -> /dashboard
Reading URLs off that tree means ignoring every parenthesized folder and treating every other folder as a segment. (marketing)/pricing/page.tsx emits /pricing, while a plain pricing/ folder would emit the same path — the parentheses change which layout wraps the page and nothing else. In the build output you can confirm it: the route table lists /pricing, with no mention of the group.
Groups compose, and that is where they get useful. Nesting is allowed — (marketing)/(campaigns)/summer/page.tsx still emits /summer — and a group can sit anywhere a normal folder can, including beside a dynamic folder ((shop)/[category]/page.tsx emits /[category]). The rule never changes: strip the parens, join the rest with /.
Layout nesting follows the stripped path, not the folder path. app/(marketing)/layout.tsx wraps (marketing)/pricing/page.tsx, and if a group contains its own subfolder layout, both nest in the usual root-first order. What a group layout can not do is wrap anything outside its group — a (app) layout never sees a marketing page, which is the entire point. Loading and error boundaries behave the same way: app/(marketing)/loading.tsx covers only the group's routes during initial load, so marketing can stream a shell while the dashboard renders its own.
What groups do not change is just as important. page.tsx, layout.tsx, loading.tsx, error.tsx, not-found.tsx, and route.ts keep their special meaning inside a group; a layout in the group wraps only the group's pages; and imports are unaffected, so a shared components/Nav.tsx can be imported by both groups without living inside either. Data fetching, caching, and dynamic rendering rules are identical — groups are a filesystem and layout concern, never a rendering one.
There is exactly one failure mode to internalize: two groups can emit the same path. If (marketing)/pricing/page.tsx and (legacy)/pricing/page.tsx both exist, /pricing has two handlers and the build refuses to choose. Route groups remove a level from the URL, so they make accidental collisions easier to create, not harder — check the route table after a big reorganization rather than trusting the folder tree.
A second trap is subtler: a group does not scope the URL, so moving a page between groups changes only which layout renders, while moving it out of a group into a plain folder changes nothing either. Only the segments that remain after stripping determine the link. That property is what the next topic leans on — different layouts over the same URL space — and the underscore prefix, covered later, is the opposite move: a folder that keeps its files out of routing entirely.
Two Shells, One Codebase: Splitting Marketing and App Layouts with Groups
The concrete problem: marketing pages need a footer, a cookie banner, and a different font stack; the dashboard needs a sidebar, an auth guard, and none of that chrome. Without groups you end up with one root layout that branches on pathname — a usePathname() check in a server-owned shell, which is exactly the kind of conditional that rots. With groups, each side owns a layout and neither knows about the other.
app/
(marketing)/
layout.tsx -> <html>, marketing nav, footer, banner
page.tsx -> /
pricing/page.tsx -> /pricing
(app)/
layout.tsx -> <html>, sidebar, <RequireSession>
dashboard/page.tsx-> /dashboard
settings/page.tsx -> /settings
Two variants exist, and the difference is whether a root layout exists above the groups.
One shared root layout. Keep app/layout.tsx rendering <html><body> plus fonts, providers, and the global error boundary. Both group layouts then render into children. Navigation between any two pages keeps the root mounted — providers, theme, and analytics session persist across a marketing-to-dashboard transition. This is the default you want.
Multiple root layouts. Delete app/layout.tsx entirely and let each group's layout render its own <html> and <body>. Now the marketing site and the app are separate documents: different <head>, different scripts, no shared providers. The cost is real — every group must declare a root layout, a page.tsx outside any group has nothing to render inside, and global 404 handling has to be arranged per group. Teams reach for this when one half of the app is a distinct microsite; otherwise it is a footgun.
The auth example shows why groups beat middleware for layout concerns. The dashboard layout can await a session check and call redirect('/login') before any page renders, which puts the guard next to the thing it guards — visible in the folder tree, reusable by every child, and re-evaluated on client-side navigation into the group. Middleware still earns its keep for pre-render logic (redirecting /wp-admin probes, rewriting before the request exists), but a layout guard survives review better than a URL regex in a separate file.
Shared components stay outside the groups — app/components/, lib/, and app/(marketing)/components/ are all importable from anywhere, because groups affect routing only. A common convention: put genuinely shared UI at app/_components/ (underscore, so it can never be routed) and group-specific UI inside the group, so an import path tells you the blast radius before you read the file.
Moving a page between groups is a deliberately boring operation: the URL stays identical, the page file keeps its contents, and exactly one thing changes — which layout, loading boundary, and error boundary wrap it. That makes group refactors safe to do late in a project, with one caveat: any relative import in the moved file may now resolve differently, and any layout-scoped context (an auth provider set up in the old group) stops applying. Verify with two clicks after the move: one navigation inside the page's own group, one navigation to a sibling still in the old group.
One navigation detail to remember when you split shells: a <Link> from marketing into the dashboard crosses layouts, so the root layout persists but the group layout mounts fresh — exactly like any other segment change. Prefetching still applies, and loading.tsx inside the group still gives the click an instant fallback. The router's compositing rules from App Router Fundamentals are unchanged; groups only decide which layouts sit on the path.
Colocating Without Publishing: Underscore Folders and How Groups Leak Assumptions
A folder is a URL until you prefix it with an underscore. app/_lib/scoring.ts, app/dashboard/_components/Sparkline.tsx, and app/blog/_fixtures/posts.json are all invisible to the router: no URL can ever be produced from an underscore-prefixed folder, regardless of what files sit inside it. A page.tsx under app/_templates/page.tsx never serves a route; it is just a file the router skips.
That gives you colocation — the practice of keeping a feature's components, tests, types, and fixtures next to the code that uses them instead of in a distant src/components tree that nobody can navigate:
app/orders/
page.tsx -> /orders
[id]/page.tsx -> /orders/42
_components/OrderTable.tsx <- not routable, imported by the pages above
_lib/formatMoney.ts <- not routable
OrderTable.test.tsx <- tests are safe: only page/route files create routes
Note the last line: only page.*, route.*, and the special files (layout, loading, error, not-found, template, default) are meaningful to the router, so a random .tsx in app/orders/ was never routable to begin with. The underscore exists for the files that are meaningful — a page.tsx you want to keep as a template, or a whole app/(archive)/ you want to disable without deleting.
Route groups and underscores solve overlapping problems with opposite semantics, and mixing them up is a common review comment:
| Construct | Produces a URL? | Wraps in a layout? | Typical use |
|---|---|---|---|
(marketing) |
yes, minus the parens | yes | split shells, organize routes |
_marketing |
never | no | colocation, dead templates |
plain marketing |
yes, as /marketing |
yes | a real segment |
Where the leaks happen. First, an underscore folder in the middle of a path does not truncate the URL — it removes the folder entirely, so a private folder never contributes a segment. Second, groups can resurrect assumptions: a relative import that worked when both folders were siblings (../../lib/x) may now cross a group boundary and compile fine while pointing at a duplicate file, which is why absolute imports (@/lib/x) belong in a project that reorganizes folders often. Third, and most annoying: an optional catch-all in one group and a plain page in another can collide only after stripping, so the build — not your editor's file tree — is the authority on which URLs exist.
Use the build output as your periodic audit. next build prints every emitted route; if a path appears that you thought was private, a paren is missing; if a path you linked is absent, an underscore is in the wrong place. Route groups, private folders, and brackets are all just punctuation acting on the filesystem — the emitted route table is the only ground truth, and reading it quickly is a genuinely useful interview skill.
One colocation detail that trips up test setups: test files under app/ are safe from the router, but some Jest and Vitest configurations scan every *.test.tsx as a route-shaped module or complain about non-page files in the tree. The fix is configuration, not deletion — scope the test matcher, or keep route-level tests in a top-level tests/ folder while unit tests for _components stay colocated. The router's stance is simple: only the special filenames matter, so Sparkline.test.tsx is invisible to routing no matter where it lives.
Up next: Parallel and Intercepting Routes, which uses another kind of special folder name — the @ prefix — to render several regions from one route.
Quiz
1. With app/(marketing)/pricing/page.tsx and app/(app)/dashboard/page.tsx, which URLs are emitted?
2. What do you need for two groups to each render their own <html> and <body>?
3. You add app/_templates/page.tsx and app/orders/_components/Sparkline.tsx. What happens?
4. The build fails saying two files resolve to /pricing after you reorganized folders. What is the likely cause?
Flashcards
Question
What does a parenthesized folder do to the URL, and what does it preserve?
Click to reveal answer
Answer
It is stripped: (marketing)/pricing/page.tsx emits /pricing. Everything else is preserved — page/layout/loading/error semantics, layouts wrapping the group's pages, and all rendering/caching rules. Only the folder name disappears from the emitted path.
Question
How do route groups enable separate marketing and dashboard shells?
Click to reveal answer
Answer
Each group carries its own layout, so marketing gets nav/footer and the dashboard gets a sidebar plus an auth guard, without conditional branches in one shell. Either keep one shared app/layout.tsx above both (shared providers persist across navigation) or delete it so each group is an independent root layout rendering its own <html>/<body>.
Question
What does an underscore-prefixed folder change, and what stays true inside it?
Click to reveal answer
Answer
It opts the folder out of routing: no URL can be produced from it, even for a page.tsx inside. Imports still work, so components, tests, fixtures, and dead templates can be colocated with the pages that use them.
Question
Route group vs private folder vs plain folder — one-line contrast.
Click to reveal answer
Answer
Group (name): routable, layout-wrapping, name stripped from URL. Private (_name): never routable, no layout, no URL contribution. Plain name: routable as a literal segment. Colocation -> private; separate shells -> group.
Question
What is the main collision risk route groups introduce, and how do you catch it?
Click to reveal answer
Answer
Two groups can strip down to the same path (both have pricing/page.tsx -> /pricing), which the build reports as ambiguous. Catch it with `next build`'s route table — the emitted routes, not the folder tree, are the authority after any reorganization.
Revision Notes
Key Takeaways
- 1. Parenthesized folders are organizational only — strip them and the remaining segments are the URL
- 2. Groups exist to attach different layouts to a URL space; multiple root layouts require deleting app/layout.tsx and rendering <html> per group
- 3. Underscore-prefixed folders are invisible to the router, which is what makes colocating components and templates safe
- 4. Because groups vanish from paths, two groups can emit the same URL — the build catches it, the folder tree will not
- 5. Groups change layout and organization, never rendering, caching, or data-fetching semantics
Interview Tips
- • "What is a route group?" — a parenthesized folder stripped from the URL that exists to attach a layout; add that pages inside still behave like any page and that the strip rule is how you read a tree to its URLs
- • "How would you put a marketing site and a dashboard in one app?" — groups with separate layouts under one shared root (my default), or multiple root layouts if they must be separate documents; mention the auth guard living in the dashboard layout
- • "What does _components buy me?" — colocation with guaranteed non-routability; note that only page/route and special files create routes, so the underscore mainly protects meaningful filenames
- • "How do you audit routing after a refactor?" — `next build` route table: check every emitted path against intent; duplicate paths surface as ambiguity errors, stray underscores or parens as missing or unexpected URLs
Cheat Sheet
Route Groups and Private Folders
Read a tree to its URLs: ignore every (group) folder, drop every _folder, join the rest with /.
app/
(marketing)/layout.tsx wraps the group
(marketing)/pricing/page.tsx -> /pricing
(app)/dashboard/page.tsx -> /dashboard
_components/Button.tsx -> no URL, importable
orders/[id]/page.tsx -> /orders/:id
Two ways to split shells:
| Setup | Result |
|---|---|
keep app/layout.tsx, layouts in each group |
one document; shared providers/fonts persist across navigation |
delete app/layout.tsx, each group renders <html><body> |
two independent microsites; every group must be a root, pages outside a group have nothing to render |
Auth belongs where it is visible: a group layout can await the session and redirect('/login') before any child renders — guard sits next to what it guards and re-runs on navigation into the group.
Private folders: _name -> router never emits a URL (even for page.tsx inside). Only page/route/layout/loading/error/not-found/template/default files are meaningful to the router anyway; tests and components were always safe.
Gotchas:
- Groups make duplicate paths easier:
(a)/pricing+(b)/pricing-> build error. - Stripping applies to the whole path, never mid-path truncation semantics beyond removing the folder.
- Reorganize with absolute imports (
@/lib/...); relative../../paths rot when folders move. - Audit with
next buildroute table: missing URL = stray underscore; unexpected URL = stray parens.