---
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