Advanced App Router Routing Patterns (Next.js)

Advanced App Router Routing Patterns (Next.js)

The App Router is more than nested folders. Below are lesser-known patterns I use in real projects to compose UX, keep code maintainable, and ship fast.

Use these as bite-sized recipes; each has a minimal example you can paste into a new Next.js app (App Router enabled).


1) Parallel routes for composable shells

Render multiple route trees in one layout using parallel segments. Great for dashboards with a persistent sidebar or a live preview alongside an editor.

Folder shape:

app/
  layout.tsx
  @nav/        ← parallel slot
    page.tsx
  @content/    ← parallel slot
    page.tsx

Layout mounts the slots by name:

// app/layout.tsx
export default function RootLayout({
  children,
  nav,
  content,
}: {
  children: React.ReactNode;
  nav: React.ReactNode;
  content: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <aside>{nav}</aside>
        <main>{content ?? children}</main>
      </body>
    </html>
  );
}

Overview:

Notes:

  • Each slot has its own loading.tsx and error.tsx.
  • You can navigate within a slot without re-rendering siblings.

2) Intercepted routes for modal/detail overlays

Open a detail page as a modal over the current list without losing the list state, and open the same route as a full page when navigated directly.

Folder shape:

app/
  feed/
    page.tsx
    @modal/(..)post/[id]/page.tsx   ← intercept from feed
  post/
    [id]/page.tsx                   ← canonical page

In feed/page.tsx, render the @modal slot where the modal should appear:

// app/feed/page.tsx
export default function Feed({ modal }: { modal: React.ReactNode }) {
  return (
    <>
      <ul>{/* feed items */}</ul>
      {modal}
    </>
  );
}

Notes:

  • (..)post/[id] means “use the route from a sibling/parent when intercepted”.
  • Visiting /post/123 directly renders the full page; clicking from /feed shows the modal overlay.

3) Route groups to control layout boundaries

Group folders without affecting the URL. Useful for changing layouts per section or isolating error/loading boundaries.

app/
  (marketing)/
    layout.tsx
    page.tsx
  (app)/
    layout.tsx
    dashboard/page.tsx

URL stays /dashboard; the (app) layout wraps only the app area.


4) Optional catch-all with smart 404s

Use optional segments to serve nested content while returning proper 404s for unknown paths.

// app/docs/[[...slug]]/page.tsx
import { notFound } from 'next/navigation';

export default async function Docs({ params }: { params: { slug?: string[] }}) {
  const path = (params.slug ?? []).join('/');
  const doc = await fetchDoc(path); // your lookup
  if (!doc) return notFound();
  return <article dangerouslySetInnerHTML={{ __html: doc.html }} />;
}

Tips:

  • Pair with generateStaticParams for hot paths, then fallback dynamic fetch for the rest.

5) Segment-level loading and error boundaries

Each segment can define loading.tsx and error.tsx. Errors bubble up until a boundary catches them.

// app/projects/[id]/error.tsx
'use client';
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div>
      <h2>Failed to load project</h2>
      <pre>{error.message}</pre>
      <button onClick={reset}>Retry</button>
    </div>
  );
}

6) Route Handlers with fine-grained caching

Control HTTP caching and ISR on a per-request basis.

// app/api/search/route.ts
export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const q = searchParams.get('q') ?? '';

  // Cache popular empty query for 60s, but user queries are dynamic.
  const isPopular = q === '';
  const data = await search(q);

  return new Response(JSON.stringify(data), {
    headers: {
      'content-type': 'application/json',
      'cache-control': isPopular ? 's-maxage=60, stale-while-revalidate=300' : 'no-store',
    },
  });
}

Also handy: next: { revalidate: 60 } in fetch, or export const dynamic = 'force-static' | 'force-dynamic' in the segment.


7) generateStaticParams with partial prebuild

Pre-render hot routes and keep the rest dynamic to avoid massive builds.

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const hot = await getTopBlogSlugs(); // top 100
  return hot.map(slug => ({ slug }));
}

export const dynamicParams = true; // non-listed slugs are rendered on-demand
export const revalidate = 60;      // and revalidated periodically

8) Locale-aware routing without changing URLs

Use middleware to detect locale and set a header/cookie; use a route group to wrap a locale provider, but keep clean URLs.

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(req: NextRequest) {
  const url = req.nextUrl
  const locale = req.cookies.get('locale')?.value || req.headers.get('accept-language')?.split(',')[0] || 'en'
  const res = NextResponse.next()
  res.headers.set('x-locale', locale)
  return res
}
// app/(locale)/layout.tsx
export default function LocaleLayout({ children }: { children: React.ReactNode }) {
  // Read header via server only APIs if needed (e.g., headers())
  return <html lang="en"><body>{children}</body></html>;
}

9) Slot-only pages: replace children entirely

You can omit children and render only parallel slots for shell UIs.

// app/layout.tsx
export default function RootLayout({ nav, content }: { nav: React.ReactNode; content: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <aside>{nav}</aside>
        <main>{content}</main>
      </body>
    </html>
  );
}

10) Robust notFound + redirect control flow

Use notFound() and redirect() for clear server-driven control, instead of leaking 404/302 into client code.

// app/orgs/[id]/page.tsx
import { redirect, notFound } from 'next/navigation';

export default async function OrgPage({ params }: { params: { id: string }}) {
  const org = await getOrg(params.id)
  if (!org) return notFound()
  if (!org.active) redirect(`/orgs/${params.id}/billing`)
  return <h1>{org.name}</h1>
}

Testing your tree quickly

  • Use npx next build && npx next analyze or the built-in route tree in dev to spot unexpected boundaries.
  • Add tiny loading.tsx and error.tsx files to visualize segment behavior.
  • Prefer server-driven control (notFound/redirect) to avoid double renders.

If you want, I can turn any pattern above into a tiny starter repo with both the canonical route and the intercepted/modal variant working side by side.

Read more