🗃️ Chapter 6: Route Groups & Organization
Giving different sections of your app completely different shells, without those sections showing up in the URL.
📖 Plain English Explanation
Here's a problem every real app runs into.
Your marketing pages (/, /pricing, /about) need a public header with a "Sign up" button and a big footer with links to your social media.
Your app pages (/dashboard, /settings, /billing) need a sidebar, a user menu, and no footer at all.
Both live at the top level of your URL structure. You can't put the dashboard pages inside an app/dashboard/ folder because then they'd be at /dashboard/settings instead of /settings. And layouts follow folders, so if the folders are flat, the layouts have to be too.
Route groups solve exactly this. Wrap a folder name in parentheses and it organizes your files without appearing in the URL:
app/
├── (marketing)/
│ ├── layout.tsx ← public header + footer
│ ├── page.tsx → /
│ └── pricing/page.tsx → /pricing
└── (app)/
├── layout.tsx ← sidebar + user menu
├── dashboard/page.tsx → /dashboard
└── settings/page.tsx → /settings
Two completely separate shells. The URLs stay clean. Nobody visiting your site ever sees the word "marketing".
🎯 The three jobs route groups do
Job 1: Different layouts at the same URL level
This is the main use, shown above. The key insight: a route group creates a new layout boundary without creating a URL boundary.
// app/(marketing)/layout.tsx
import { PublicHeader } from '@/components/PublicHeader'
import { Footer } from '@/components/Footer'
export default function MarketingLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<PublicHeader />
<main className="mx-auto max-w-5xl px-6">{children}</main>
<Footer />
</>
)
}
// app/(marketing)/layout.js
import { PublicHeader } from '@/components/PublicHeader'
import { Footer } from '@/components/Footer'
export default function MarketingLayout({ children }) {
return (
<>
<PublicHeader />
<main className="mx-auto max-w-5xl px-6">{children}</main>
<Footer />
</>
)
}
// app/(app)/layout.tsx
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { Sidebar } from '@/components/Sidebar'
export default async function AppLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await getSession()
if (!session) redirect('/login')
return (
<div className="flex min-h-screen">
<Sidebar user={session.user} />
<div className="flex-1 p-8">{children}</div>
</div>
)
}
// app/(app)/layout.js
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { Sidebar } from '@/components/Sidebar'
export default async function AppLayout({ children }) {
const session = await getSession()
if (!session) redirect('/login')
return (
<div className="flex min-h-screen">
<Sidebar user={session.user} />
<div className="flex-1 p-8">{children}</div>
</div>
)
}
Notice the auth check in the (app) layout. Every route in that group is now gated in one place. That's a genuinely useful side effect of grouping by shell.
Security note: a layout auth check is a convenience, not a security boundary. Layouts don't re-run on every navigation, and Server Actions and Route Handlers bypass layouts entirely. Chapter 18 covers where the real check belongs.
Job 2: Organizing without any layout change
Sometimes you just want related routes near each other:
app/
├── (legal)/
│ ├── privacy/page.tsx → /privacy
│ ├── terms/page.tsx → /terms
│ └── cookies/page.tsx → /cookies
└── (support)/
├── help/page.tsx → /help
└── contact/page.tsx → /contact
No layout.tsx in either group — they just inherit the root layout. The parentheses are purely for humans reading the file tree.
Job 3: Opting a section out of a layout
A route group can escape a layout that would otherwise apply. Compare:
app/
├── layout.tsx ← root
├── (shop)/
│ ├── layout.tsx ← shop chrome
│ ├── products/page.tsx → /products (has shop chrome)
│ └── cart/page.tsx → /cart (has shop chrome)
└── checkout/page.tsx → /checkout (root layout only — no chrome)
/checkout sits outside (shop), so it gets a bare page with no shop navigation — exactly what you want for a distraction-free checkout flow.
🏛️ Multiple root layouts
You can go further and give groups their own <html> and <body>. This creates genuinely separate applications sharing one codebase.
The rule: delete app/layout.tsx entirely, and give each top-level group its own root layout.
app/
├── (marketing)/
│ ├── layout.tsx ← has <html> and <body>
│ ├── page.tsx → /
│ └── pricing/page.tsx → /pricing
└── (app)/
├── layout.tsx ← has its own <html> and <body>
└── dashboard/page.tsx → /dashboard
// app/(marketing)/layout.tsx
import { Playfair_Display } from 'next/font/google'
import './marketing.css'
const playfair = Playfair_Display({ subsets: ['latin'] })
export const metadata = {
title: 'Acme — Widgets for everyone',
}
export default function MarketingRootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={playfair.className}>
<body className="bg-white">{children}</body>
</html>
)
}
// app/(app)/layout.tsx
import { Inter } from 'next/font/google'
import './app.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata = {
title: 'Acme Dashboard',
}
export default function AppRootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={inter.className}>
<body className="bg-slate-50">{children}</body>
</html>
)
}
// app/(app)/layout.js
import { Inter } from 'next/font/google'
import './app.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata = {
title: 'Acme Dashboard',
}
export default function AppRootLayout({ children }) {
return (
<html lang="en" className={inter.className}>
<body className="bg-slate-50">{children}</body>
</html>
)
}
Different fonts, different global CSS, different <body> classes. Nothing shared but the codebase.
The cost
Navigating between root layouts is a full page reload. Going from /pricing to /dashboard tears down the document and starts fresh — the white flash is back.
That is usually fine. Users cross from marketing to app once per session, and a hard reload at that boundary is invisible in practice. But if two sections are navigated between constantly, do not split their root layouts.
| Use multiple root layouts when | Use one root layout when |
|---|---|
| Sections have different fonts / global CSS | Sections share a design system |
| Users cross the boundary rarely | Users cross constantly |
You want fully separate <head> setups | A nested layout is enough |
🌐 Route groups + dynamic root segments
A common real-world combination: internationalization with a group per audience.
app/
└── [locale]/
├── layout.tsx ← sets <html lang>, loads translations
├── (marketing)/
│ ├── layout.tsx
│ ├── page.tsx → /en, /fr
│ └── pricing/page.tsx → /en/pricing, /fr/pricing
└── (app)/
├── layout.tsx
└── dashboard/page.tsx → /en/dashboard, /fr/dashboard
// app/[locale]/layout.tsx
export function generateStaticParams() {
return [{ locale: 'en' }, { locale: 'fr' }, { locale: 'de' }]
}
export default async function LocaleLayout(props: LayoutProps<'/[locale]'>) {
const { locale } = await props.params
return (
<html lang={locale}>
<body>{props.children}</body>
</html>
)
}
// app/[locale]/layout.js
export function generateStaticParams() {
return [{ locale: 'en' }, { locale: 'fr' }, { locale: 'de' }]
}
export default async function LocaleLayout({ children, params }) {
const { locale } = await params
return (
<html lang={locale}>
<body>{children}</body>
</html>
)
}
Components deep inside can read the locale without prop drilling, using next/root-params from Chapter 5:
// lib/t.ts
import { locale } from 'next/root-params'
export async function t(key: string) {
const current = await locale()
const dict = await import(`@/messages/${current}.json`)
return dict.default[key] ?? key
}
🧱 A realistic full structure
Here's what a mature app looks like with everything from Chapters 2 and 6 combined:
app/
├── layout.tsx ← root: <html>, <body>, providers
├── globals.css
├── not-found.tsx ← global 404
│
├── (marketing)/
│ ├── layout.tsx ← public header + footer
│ ├── page.tsx → /
│ ├── pricing/page.tsx → /pricing
│ ├── blog/
│ │ ├── page.tsx → /blog
│ │ └── [slug]/page.tsx → /blog/:slug
│ └── _components/
│ └── PricingTable.tsx ← private, never routed
│
├── (auth)/
│ ├── layout.tsx ← centered card, no nav
│ ├── login/page.tsx → /login
│ ├── register/page.tsx → /register
│ └── forgot-password/page.tsx → /forgot-password
│
├── (app)/
│ ├── layout.tsx ← auth gate + sidebar
│ ├── dashboard/
│ │ ├── page.tsx → /dashboard
│ │ ├── loading.tsx
│ │ └── error.tsx
│ ├── settings/
│ │ ├── layout.tsx ← settings sub-nav
│ │ ├── page.tsx → /settings
│ │ └── billing/page.tsx → /settings/billing
│ └── _components/
│ └── Sidebar.tsx
│
├── api/
│ └── webhooks/
│ └── stripe/route.ts → /api/webhooks/stripe
│
└── proxy.ts ← runs before requests
Read that tree top to bottom and you understand the whole application. That's the goal.
⚠️ Common Pitfalls
1. Two groups producing the same URL
app/
├── (marketing)/about/page.tsx → /about
└── (legal)/about/page.tsx → /about ❌ conflict
Error: You cannot have two parallel pages that resolve to the same path
Parentheses are invisible to the router, so both really are /about. Fix: rename one route.
The subtle version:
app/
├── (marketing)/page.tsx → /
└── (app)/page.tsx → / ❌ same conflict
Only one group can own the index route.
2. Root layout plus group root layouts
app/
├── layout.tsx ← has <html>
├── (marketing)/layout.tsx ← also has <html> ❌ nested <html>
└── (app)/layout.tsx ← also has <html> ❌
If you want multiple root layouts, app/layout.tsx must not exist. It's all-or-nothing.
3. Expecting shared state across root layouts
// app/(marketing)/layout.tsx
<CartProvider>{children}</CartProvider>
// app/(app)/layout.tsx
<CartProvider>{children}</CartProvider>
These are two different React trees. Crossing between them is a full page load and all in-memory state is destroyed. Fix: persist to cookies, localStorage, or the server — not React state.
4. Assuming the group name affects the URL
app/(dashboard)/settings/page.tsx
This is /settings, not /dashboard/settings. If you want the URL segment, drop the parentheses.
5. Treating a layout auth check as security
// app/(app)/layout.tsx
const session = await getSession()
if (!session) redirect('/login')
Good UX, insufficient security. Layouts don't re-run on every client navigation, and nothing stops a request hitting a Server Action or Route Handler directly. Fix: check authorization in the data access layer, close to the data. Chapter 18.
6. Over-grouping
app/
└── (site)/
└── (public)/
└── (content)/
└── (blog)/
└── posts/page.tsx
Four groups, one route, zero benefit. Add a group when you need a layout boundary or a genuine organizational win — not by reflex.
🎯 When & Why to Use
Route group with a layout → a section needs a different shell
Route group without a layout → organizing files; no routing effect
Multiple root layouts → genuinely separate apps (different fonts,
CSS, <head>) crossed rarely
Regular folder → you actually want the URL segment
Private folder (_name) → colocated code that must never route
Decision flow:
Do these routes need a different wrapper UI?
├── No → do they just belong together conceptually?
│ ├── Yes → route group, no layout
│ └── No → leave them flat
└── Yes → Do they also need different <html>/<body>/fonts/global CSS?
├── No → route group with a layout ← most common
└── Yes → multiple root layouts (accept the hard nav)
🏋️ Mini Practice Problems
Problem 1: URL mapping
List every URL this tree serves:
app/
├── layout.tsx
├── (public)/
│ ├── layout.tsx
│ ├── page.tsx
│ └── about/page.tsx
├── (dash)/
│ ├── layout.tsx
│ ├── home/page.tsx
│ └── (settings)/
│ ├── profile/page.tsx
│ └── security/page.tsx
└── _shared/
└── Header.tsx
Problem 2: Fix the conflict
This build fails. Explain the error and give two different fixes:
app/
├── (marketing)/
│ └── page.tsx
└── (app)/
└── page.tsx
Problem 3: Design it
An app needs:
- Marketing pages with a serif font and a public nav
- Auth pages (
/login,/register) with a centered card, no nav at all - A dashboard with a sidebar and a sans-serif font, gated behind login
- Marketing and dashboard share no CSS whatsoever
Sketch the app/ tree. Say where each layout.tsx lives, and whether app/layout.tsx exists.
Problem 4: Predict the cost
In your Problem 3 answer, what happens — technically — when a logged-in user clicks a link from /pricing to /dashboard? Is that acceptable? When would it not be?
💼 Interview Notes
Common Questions
Q: What is a route group and why would you use one? A folder wrapped in parentheses. It organizes routes and can introduce a layout boundary without contributing a URL segment. The main use is giving different sections of an app different shells at the same URL level.
Q: How do you give marketing pages and dashboard pages different layouts when both live at the root?
Route groups: app/(marketing)/ and app/(app)/, each with its own layout.tsx. URLs stay at the root; layouts diverge.
Q: What are multiple root layouts and what do they cost?
Delete app/layout.tsx and give each top-level group a layout containing <html> and <body>. Each group becomes an independent document with its own fonts, global CSS, and metadata. The cost is a full page reload when navigating between groups, and no shared client state.
Q: Can two route groups contain the same route?
No. Parentheses are stripped when resolving URLs, so (a)/about and (b)/about both resolve to /about and the build fails with a parallel-pages error.
Q: Is putting an auth check in a group's layout enough to secure it? No. It's good UX — unauthenticated users get bounced early — but layouts don't re-run on every navigation, and Server Actions and Route Handlers don't pass through them. Authorization belongs next to the data.
🏢 Asked at Companies
- Vercel: "Structure an app with a public site, an auth flow, and a gated dashboard. Where does each layout go?"
- Linear: "When would you accept a full page reload between two sections of your app?"
- Shopify: "A storefront and an admin panel share a codebase but no design system. How do you organize
app/?" - Stripe: "A code review shows the only auth check is in a layout. What's your feedback?"
📊 Visual Memory Aid
FOLDER NAME → URL EFFECT
app/dashboard/page.tsx → /dashboard (segment added)
app/(dashboard)/page.tsx → / (no segment)
app/_dashboard/page.tsx → (not a route at all)
ONE ROOT vs MANY ROOTS
┌── ONE ROOT LAYOUT ──────────────────────┐
│ app/layout.tsx <html><body> │
│ ├── (marketing)/layout.tsx │
│ └── (app)/layout.tsx │
│ │
│ /pricing → /dashboard = SOFT nav ⚡ │
│ shared providers, shared state │
└──────────────────────────────────────────┘
┌── MULTIPLE ROOT LAYOUTS ────────────────┐
│ (no app/layout.tsx) │
│ ├── (marketing)/layout.tsx <html> │
│ └── (app)/layout.tsx <html> │
│ │
│ /pricing → /dashboard = HARD reload 🔄 │
│ separate fonts, CSS, <head>, state │
└──────────────────────────────────────────┘
LAYOUT BOUNDARY, NOT URL BOUNDARY
app/
├─ (marketing)/ ┐
│ layout.tsx │ different shells
├─ (app)/ │ same URL depth
│ layout.tsx ┘
🎯 Key Takeaways
(parentheses)create a layout boundary without a URL segment — the tool for giving sections different shells at the same URL level.- Route groups are invisible to the router. Two groups can't own the same path, and the group name never appears in a URL.
- Multiple root layouts mean fully separate apps — own
<html>, fonts, and CSS — at the cost of a hard reload and no shared state at the boundary. - Grouping by shell gives you a natural place for section-wide concerns like an auth redirect, but a layout check is UX, not security.
- Don't group by reflex. Add a group when you need a layout boundary or a real organizational win, and leave the tree flat otherwise.
Next Chapter: Parallel & Intercepting Routes →
Practice: Restructure an existing app into (marketing), (auth), and (app) groups with three distinct layouts. Then try converting them to multiple root layouts, and observe the difference in the Network tab when navigating between groups.