frontend / project structure / 04_nextjs_app_router_layout.md

Next.js App Router Layout — Segments, Parallel Routes, Intercepts

7 min read source

Next.js App Router Layout — Segments, Parallel Routes, Intercepts

TL;DR

Next.js 13+’s App Router rebuilt routing around the file system + React Server Components. Key concepts: segments (folders = URL segments), layouts (persistent wrappers per segment), loading/error/not-found UIs per segment (Suspense + ErrorBoundary scaffolded automatically), route groups (organize without affecting URLs), parallel routes (@slot for sidebar-like simultaneous routes), and intercepting routes ((.)/(..) to show a modal on top of the previous page). A senior should be able to design a layout for “modal-based detail view” or “dashboard with three independent panels” using these primitives.

Interview Q&A

Q: Basic App Router layout.

A:

app/
├── layout.tsx                    # root layout (HTML shell)
├── page.tsx                      # /
├── loading.tsx                   # /  shown while page.tsx suspends
├── error.tsx                     # /  catches errors
├── not-found.tsx
├── dashboard/
│   ├── layout.tsx                # /dashboard layout (persistent)
│   ├── page.tsx                  # /dashboard
│   └── settings/
│       └── page.tsx              # /dashboard/settings
└── posts/
    └── [id]/
        └── page.tsx              # /posts/123 (dynamic segment)
  • page.tsx = the route’s leaf (the actual page content).
  • layout.tsx = persistent wrapper; doesn’t re-render on child navigation.
  • loading.tsx / error.tsx / not-found.tsx = auto-Suspense / ErrorBoundary / 404 boundary per segment.

Q: What’s a layout, and why “persistent”?

A: A layout.tsx wraps its segment + descendants. When the user navigates within the layout’s scope, the layout doesn’t unmount — only the page changes.

// app/dashboard/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="dashboard">
      <Sidebar />
      <main>{children}</main>
    </div>
  );
}

Navigating /dashboard/dashboard/settings keeps Sidebar mounted; only main content swaps. Faster nav, preserved state (scroll, form input).

Compare to Pages Router (_app.tsx): one global wrapper, less granular.

Q: Route groups — (group)/.

A: A folder in parentheses doesn’t affect the URL — used to share a layout across routes without nesting them in a URL segment.

app/
├── (marketing)/
│   ├── layout.tsx                # marketing layout
│   ├── page.tsx                  # /
│   ├── about/page.tsx            # /about
│   └── pricing/page.tsx          # /pricing
└── (app)/
    ├── layout.tsx                # app layout (with sidebar, auth)
    ├── dashboard/page.tsx        # /dashboard
    └── settings/page.tsx         # /settings

URLs: /, /about, /pricing, /dashboard, /settings. Two distinct layouts apply; no URL pollution.

Common pattern: (public) vs (auth) groups to separate logged-out and logged-in experiences.

Q: Parallel routes — @slot/.

A: Renders multiple routes simultaneously in one layout. Each slot is a named “@” folder; the layout receives them as props.

app/
└── dashboard/
    ├── layout.tsx
    ├── @analytics/
    │   └── page.tsx
    ├── @team/
    │   └── page.tsx
    └── page.tsx                  # default content
// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  team: React.ReactNode;
}) {
  return (
    <div className="grid grid-cols-3">
      <main>{children}</main>
      <aside>{analytics}</aside>
      <aside>{team}</aside>
    </div>
  );
}

Use cases: dashboards with independently-loaded panels, conditional rendering (auth status changes which slot renders), simultaneous error/loading states per slot.

Each slot gets its own loading.tsx/error.tsx. Powerful but easy to over-use; reach for it when slots are genuinely independent.

Q: Intercepting routes — (.) (..) (...).

A: Render a route as modal on top of the previous one instead of a full page transition. The URL changes (deep-linkable), but visually it’s an overlay.

app/
├── feed/
│   ├── page.tsx                  # /feed
│   └── @modal/
│       └── (..)photo/
│           └── [id]/
│               └── page.tsx      # intercepted /photo/:id
└── photo/
    └── [id]/
        └── page.tsx              # /photo/:id (full page on direct visit)

The @modal slot in feed/ intercepts /photo/:id when navigated from /feed — shows as modal. Direct visit to /photo/:id renders the full page.

This is exactly Instagram’s “click photo from feed → modal; share link → full page” pattern. Hard to build by hand, trivial with intercepting routes.

Conventions:

  • (.)route — same level.
  • (..)route — one level up.
  • (...)route — root-level.

Q: Server Components default — what changes?

A: Every page.tsx/layout.tsx/loading.tsx is a Server Component by default. They run on the server, can be async, fetch data directly, ship zero JS for themselves.

// app/posts/[id]/page.tsx (server component)
export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await db.posts.findUnique({ where: { id: params.id } });
  return <article>{post.body}</article>;
}

To make something client-side (with hooks, event handlers), mark with "use client" at the top. See ../05_react/server_components.md.

Q: Data fetching — fetch with caching.

A: App Router extends fetch with caching directives:

// Cache forever (default in server components when called from a Server Component)
const data = await fetch("/api/x");

// Don't cache — always fresh
const data = await fetch("/api/x", { cache: "no-store" });

// Revalidate every 60s (ISR-style)
const data = await fetch("/api/x", { next: { revalidate: 60 } });

// Tag for on-demand revalidation
const data = await fetch("/api/x", { next: { tags: ["users"] } });
// Later: revalidateTag("users") to invalidate

Caching behavior changed across Next versions (15 made caching opt-in). Verify your version’s default; pin behavior explicitly with cache: / next.revalidate.

Q: Where do API routes live?

A: app/api/.../route.ts — file-based routing for HTTP handlers:

// app/api/users/route.ts
export async function GET(req: Request) {
  const users = await db.users.findMany();
  return Response.json(users);
}

export async function POST(req: Request) {
  const body = await req.json();
  const user = await db.users.create({ data: body });
  return Response.json(user, { status: 201 });
}
// app/api/users/[id]/route.ts
export async function GET(req: Request, { params }: { params: { id: string } }) {
  const user = await db.users.findUnique({ where: { id: params.id } });
  if (!user) return new Response("Not found", { status: 404 });
  return Response.json(user);
}

For monorepo-style backend separation, you might run a separate API service instead — but for simpler apps, app/api/ keeps the BFF colocated.

Q: Metadata API for SEO.

A: Export metadata from a page.tsx/layout.tsx:

// app/posts/[id]/page.tsx
import { Metadata } from "next";

export async function generateMetadata({ params }): Promise<Metadata> {
  const post = await db.posts.findUnique({ where: { id: params.id } });
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: { images: [{ url: post.coverImage }] },
  };
}

export default async function PostPage(...) { ... }

Replaces the old <Head> hack. Type-safe, server-rendered, supports OG/Twitter cards/etc.

Q: A real-world layout pattern.

A: SaaS dashboard with auth, marketing, app:

app/
├── layout.tsx                    # global providers (theme, fonts)
├── (marketing)/
│   ├── layout.tsx                # marketing nav + footer
│   ├── page.tsx                  # /
│   ├── pricing/page.tsx
│   └── blog/[slug]/page.tsx
├── (auth)/
│   ├── login/page.tsx            # /login
│   ├── signup/page.tsx
│   └── layout.tsx                # centered auth card layout
└── (app)/
    ├── layout.tsx                # sidebar, top bar, auth check
    ├── dashboard/
    │   ├── @overview/page.tsx
    │   ├── @recent-activity/page.tsx
    │   ├── @notifications/page.tsx
    │   ├── layout.tsx            # 3-column grid
    │   └── page.tsx              # default
    └── settings/
        ├── account/page.tsx
        ├── billing/page.tsx
        ├── @modal/
        │   └── (..)settings/cancel/page.tsx    # cancel confirmation modal
        └── layout.tsx            # tabs for sub-pages

Each section has its own layout; intercepting routes for modals; parallel routes for the dashboard panels.

Gotchas / edge cases

  • Layouts don’t re-render on navigation within scope — including their data fetches. Fetch data in pages, not layouts, if it needs to refresh per route.
  • use client propagates down — once a tree starts client, deeper components are also client (until you explicitly pass a Server Component as children).
  • Parallel routes need a default.tsx for unmatched slots (otherwise the slot shows 404).
  • Intercepting routes don’t work in dev mode for direct URL entry — both renderings exist; only soft nav triggers the intercept.
  • generateStaticParams for static generation of dynamic segments at build time.
  • force-dynamic / force-static route segment config — explicit override of caching behavior.
  • Middleware (middleware.ts at app root) runs on every request — auth, redirects, headers.

What a senior is expected to say

  • “App Router rebuilt routing around RSC + file system. Folders = segments; layout.tsx is persistent; loading.tsx/error.tsx/not-found.tsx auto-scaffold Suspense + ErrorBoundary.”
  • “Route groups ((name)) organize without URL impact — (public) vs (auth) is a common split.”
  • “Parallel routes (@slot) for dashboards with independent panels; intercepting routes ((.)/(..)) for modal-over-page patterns like Instagram.”
  • “Pages/layouts are server components by default — async, direct DB access, zero client JS. 'use client' opts into interactivity.”
  • “Caching for fetch is opt-in / explicit in Next 15 — pin behavior with cache: or next.revalidate rather than relying on defaults.”

Cross-references

Further reading