6 min read
Routing & Layouts
Route groups that don't leak into the URL, parallel routes for independent panels, and loading/error boundaries scoped to the segment that actually needs them.
Principles
Layouts persist across sibling navigations
A nested `layout.tsx` doesn't remount when a sibling route changes — its state survives. Don't fight this with key-based resets, and don't assume every navigation reinitializes shared UI like a sidebar or a player.
Route groups organize without affecting the URL
A `(marketing)` folder lets you nest a layout or segment config without adding a path segment. Reach for one whenever you need a layout boundary that shouldn't show up in the URL.
Parallel routes render independent slots into one layout
`@slot` folders let a dashboard show independently-loading, independently-failing panels without one section's fetch blocking another's paint.
Intercepting routes give a modal a real URL
The `(.)`/`(..)` conventions let a photo open as an overlay from a feed while still being a shareable, refreshable, deep-linkable route — refreshing the page renders the full page instead of just the modal.
Each segment owns its own loading and error boundary
`loading.tsx` and `error.tsx` colocated with a route only wrap that segment. A slow nested page shows its own fallback instead of blanking the whole shell — nav, sidebar, and everything already loaded stays interactive.
Patterns
What goes wrong, what to do instead, and why the difference matters.
Sharing a layout without an extra URL segment
Avoid
// app/dashboard/layout.tsx wraps every dashboard route in a shared
// shell, but "dashboard" now shows up in every URL even though
// nobody asked for it.
// app/dashboard/settings/page.tsx -> /dashboard/settings
// app/dashboard/billing/page.tsx -> /dashboard/billingThe layout boundary and the URL structure are forced to match, even when they shouldn't.
Prefer
// app/(app)/layout.tsx — the shell applies without adding a segment.
// app/(app)/settings/page.tsx -> /settings
// app/(app)/billing/page.tsx -> /billing
export default function AppLayout({ children }: { children: React.ReactNode }) {
return <AppShell>{children}</AppShell>;
}Parentheses opt a folder out of the URL while every route inside it still gets the shared layout.
One slow panel blocking an unrelated one
Avoid
// A single page.tsx awaits both panels sequentially before
// anything renders.
export default async function Dashboard() {
const analytics = await getAnalytics(); // slow
const activity = await getActivity(); // fast, but waits anyway
return (
<>
<Analytics data={analytics} />
<Activity data={activity} />
</>
);
}One slow query holds up a panel that had nothing to do with it.
Prefer
// app/dashboard/@analytics/page.tsx and app/dashboard/@activity/page.tsx
// each fetch and stream independently, composed by the layout:
export default function DashboardLayout({
analytics,
activity,
}: {
analytics: React.ReactNode;
activity: React.ReactNode;
}) {
return (
<>
{analytics}
{activity}
</>
);
}Each slot is its own route segment with its own loading/error state — a slow analytics query never blocks activity from painting.
Loading state scoped to the segment that's actually slow
Avoid
// app/loading.tsx — fires for every nested navigation, including
// ones where only a small nested segment is actually loading.
export default function Loading() {
return <FullPageSpinner />;
}Every navigation anywhere in the tree replaces the entire page with a spinner, even next to content that already loaded.
Prefer
// app/reports/[id]/loading.tsx — scoped to just this segment.
export default function ReportLoading() {
return <ReportSkeleton />;
}
// The rest of the shell (nav, sidebar) stays mounted and interactive.Only the segment that's actually fetching shows a fallback; everything else stays put.
Review checklist
What I look for when reviewing a pull request that touches this area.
- Shared layouts that shouldn't appear in the URL live in a route group, not a real path segment.
- Independent sections of a page use parallel routes (`@slot`) instead of one page awaiting both.
- Every route segment with a real fetch has its own `loading.tsx`, scoped to that segment.
- Every route segment that can fail has its own `error.tsx`, not just a root-level catch-all.
- Modals that need a shareable URL use intercepting routes rather than client-side-only state.
- Layout state (tabs, scroll position, open panels) isn't reset by unrelated sibling navigations.
Take the skill
Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.
.claude/skills/nextjs-routing-layouts/SKILL.mdDrop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.
---
name: nextjs-routing-layouts
description: >-
Next.js App Router structure: route groups for layout boundaries without
URL segments, parallel routes for independent panels, segment-scoped
loading and error states, and intercepting routes for shareable modals.
Use when structuring routes or layouts.
argument-hint: "[route or layout]"
license: MIT
metadata:
author: John Felix Lim
version: "1.0.0"
---
# Routing & Layouts
Apply when adding a new route, layout, or modal. Before nesting a folder, ask whether it should affect the URL — if not, it's a route group, not a path segment.
## Rules
1. **Layouts persist across sibling navigations** — A nested `layout.tsx` doesn't remount when a sibling route changes — its state survives. Don't fight this with key-based resets, and don't assume every navigation reinitializes shared UI like a sidebar or a player.
2. **Route groups organize without affecting the URL** — A `(marketing)` folder lets you nest a layout or segment config without adding a path segment. Reach for one whenever you need a layout boundary that shouldn't show up in the URL.
3. **Parallel routes render independent slots into one layout** — `@slot` folders let a dashboard show independently-loading, independently-failing panels without one section's fetch blocking another's paint.
4. **Intercepting routes give a modal a real URL** — The `(.)`/`(..)` conventions let a photo open as an overlay from a feed while still being a shareable, refreshable, deep-linkable route — refreshing the page renders the full page instead of just the modal.
5. **Each segment owns its own loading and error boundary** — `loading.tsx` and `error.tsx` colocated with a route only wrap that segment. A slow nested page shows its own fallback instead of blanking the whole shell — nav, sidebar, and everything already loaded stays interactive.
## Patterns
### Sharing a layout without an extra URL segment
**Avoid** — The layout boundary and the URL structure are forced to match, even when they shouldn't.
```tsx
// app/dashboard/layout.tsx wraps every dashboard route in a shared
// shell, but "dashboard" now shows up in every URL even though
// nobody asked for it.
// app/dashboard/settings/page.tsx -> /dashboard/settings
// app/dashboard/billing/page.tsx -> /dashboard/billing
```
**Prefer** — Parentheses opt a folder out of the URL while every route inside it still gets the shared layout.
```tsx
// app/(app)/layout.tsx — the shell applies without adding a segment.
// app/(app)/settings/page.tsx -> /settings
// app/(app)/billing/page.tsx -> /billing
export default function AppLayout({ children }: { children: React.ReactNode }) {
return <AppShell>{children}</AppShell>;
}
```
### One slow panel blocking an unrelated one
**Avoid** — One slow query holds up a panel that had nothing to do with it.
```tsx
// A single page.tsx awaits both panels sequentially before
// anything renders.
export default async function Dashboard() {
const analytics = await getAnalytics(); // slow
const activity = await getActivity(); // fast, but waits anyway
return (
<>
<Analytics data={analytics} />
<Activity data={activity} />
</>
);
}
```
**Prefer** — Each slot is its own route segment with its own loading/error state — a slow analytics query never blocks activity from painting.
```tsx
// app/dashboard/@analytics/page.tsx and app/dashboard/@activity/page.tsx
// each fetch and stream independently, composed by the layout:
export default function DashboardLayout({
analytics,
activity,
}: {
analytics: React.ReactNode;
activity: React.ReactNode;
}) {
return (
<>
{analytics}
{activity}
</>
);
}
```
### Loading state scoped to the segment that's actually slow
**Avoid** — Every navigation anywhere in the tree replaces the entire page with a spinner, even next to content that already loaded.
```tsx
// app/loading.tsx — fires for every nested navigation, including
// ones where only a small nested segment is actually loading.
export default function Loading() {
return <FullPageSpinner />;
}
```
**Prefer** — Only the segment that's actually fetching shows a fallback; everything else stays put.
```tsx
// app/reports/[id]/loading.tsx — scoped to just this segment.
export default function ReportLoading() {
return <ReportSkeleton />;
}
// The rest of the shell (nav, sidebar) stays mounted and interactive.
```
## Review checklist
- [ ] Shared layouts that shouldn't appear in the URL live in a route group, not a real path segment.
- [ ] Independent sections of a page use parallel routes (`@slot`) instead of one page awaiting both.
- [ ] Every route segment with a real fetch has its own `loading.tsx`, scoped to that segment.
- [ ] Every route segment that can fail has its own `error.tsx`, not just a root-level catch-all.
- [ ] Modals that need a shareable URL use intercepting routes rather than client-side-only state.
- [ ] Layout state (tabs, scroll position, open panels) isn't reset by unrelated sibling navigations.
---
From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim