March 27, 2026

Next.js intercepting routes are underrated

Use Next.js intercepting routes and parallel routes to show a drawer on click and a full page on refresh — same URL, two layouts. Real code included.

next.js · app-router · routing · ux · modal · parallel-routes

Click a tool on this site's /tools page and a drawer slides in from the right. The list stays put. Refresh the page, and the same URL renders a full standalone document. Copy the link, send it to someone — they get the full page too. The URL never changed.

This is the intercepting routes pattern. It lets you show the same content in two different containers depending on how the user got there: client-side navigation from within the app, or anything else.

#How it looks

Toggle between the two navigation modes to see which files activate and what the user sees:

Active files

app/tools/
layout.tsxrenders children + drawer slot
page.tsxlist stays mounted
[slug]/
page.tsx
@drawer/
default.tsx
(.)[slug]/
page.tsxdrawer content renders

Result

/tools/formz
formz
llm-cache
r3-chat
formz

List stays mounted. Drawer slides in from the parallel slot.

Same URL in both cases. Different segments of the route tree render.

#What are parallel routes and intercepting routes

Parallel routes let multiple component trees render side by side in one layout. You create a folder prefixed with @ — say @drawer — and the layout receives it as a prop alongside children. Both trees can have their own loading states, error boundaries, and pages.

Intercepting routes only match on soft navigation (clicks on <Link>, calls to router.push). When the browser does a full page load — direct URL, refresh, back-forward cache miss — the intercepting route is skipped entirely and the normal [slug]/page.tsx renders instead. This means search engines and crawlers always see the full page, so the pattern is inherently SEO-friendly without any extra work.

The (.) prefix means "intercept at the same segment level." (..) goes up one level, (...) goes to the root. For most drawer or modal patterns, (.) is what you want.

#Folder structure

Four files do the work:

text
app/tools/
  layout.tsx              # declares the @drawer parallel slot
  page.tsx                # the list
  [slug]/
    page.tsx              # full-page detail (hard nav, SEO)
  @drawer/
    default.tsx           # renders null when no interception is active
    (.)[slug]/
      page.tsx            # detail inside drawer (soft nav only)

The @drawer folder never appears in the URL. It only controls what the layout can render.

#Walking through each file

#Layout: wire up the slot

The layout receives two props — children (the normal page tree) and drawer (the parallel slot). Render them both:

tsx
export default function ToolsLayout({
  children,
  drawer,
}: {
  children: React.ReactNode;
  drawer: React.ReactNode;
}) {
  return (
    <>
      <div className="px-1">{children}</div>
      {drawer}
    </>
  );
}

The drawer prop name must match the folder name without the @. Order matters for painting: the drawer renders after children so it stacks on top.

#Default: silence the slot

When nobody has navigated to a [slug] inside @drawer, Next.js needs a fallback. Without default.tsx, builds break:

tsx
// @drawer/default.tsx
export default function Default() {
  return null;
}

This is what makes the drawer disappear. On /tools alone, or on a hard navigation to /tools/formz, this file renders — and null means nothing shows for the slot.

#Full page: the canonical route

[slug]/page.tsx is the page everyone lands on when they visit the URL directly. It handles data fetching, metadata, and the complete layout with a back link:

tsx
export default async function ToolPage({ params }: ToolPageProps) {
  const { slug } = await params;
  const tool = await getToolBySlug(slug);
 
  if (!tool) notFound();
 
  const compiled = await compileToolMDX(tool.content);
 
  return (
    <main className="mx-auto max-w-2xl px-6 py-10">
      <ToolPageHeader
        title={tool.frontmatter.title}
        description={tool.frontmatter.description}
        // ...
      />
      <div className="mdx-content">{compiled.content}</div>
      <ToolFab url={tool.frontmatter.url} copyContent={fullMarkdown} />
    </main>
  );
}

Export generateStaticParams and generateMetadata here as usual. This is the page search engines index.

#Intercepted page: same data, different wrapper

@drawer/(.)[slug]/page.tsx loads the same data and compiles the same MDX, but wraps everything in a drawer component:

tsx
export default async function ToolDrawerPage({ params }: DrawerPageProps) {
  const { slug } = await params;
  const tool = await getToolBySlug(slug);
 
  if (!tool) notFound();
 
  const compiled = await compileToolMDX(tool.content);
 
  return (
    <ToolDrawer title={tool.frontmatter.title}>
      <div className="relative h-full overflow-y-auto">
        <ToolPageHeader
          title={tool.frontmatter.title}
          description={tool.frontmatter.description}
          compact // smaller headings, no back link
        />
        <div className="mdx-content mdx-content--compact">
          {compiled.content}
        </div>
        <ToolFab url={tool.frontmatter.url} copyContent={fullMarkdown} variant="sticky" />
      </div>
    </ToolDrawer>
  );
}

Two differences from the full page: the content is wrapped in ToolDrawer, and components accept a compact or variant prop to adapt their sizing. The data fetching is identical.

#The drawer component

The drawer is a thin client component around a Radix sheet. The important part is how it closes:

tsx
"use client";
 
import { useRouter } from "next/navigation";
import { Sheet, SheetContent } from "@/components/ui/sheet";
 
export function ToolDrawer({ children, title }: {
  children: React.ReactNode;
  title: string;
}) {
  const router = useRouter();
 
  return (
    <Sheet open onOpenChange={(open) => !open && router.back()}>
      <SheetContent title={title} side="right" className="...">
        {children}
      </SheetContent>
    </Sheet>
  );
}

router.back() is critical. It pops the intercepted route from the client-side history, which unmounts @drawer/(.)[slug]/page.tsx and restores @drawer/default.tsx — which returns null. The list reappears without the drawer. No full reload, no state loss.

If you used router.push("/tools") instead, you'd get a new history entry and the back button would reopen the drawer — not what users expect.

#Accessibility

Using Radix Dialog (via the Sheet primitive) gives you focus management and keyboard handling for free. When the drawer opens, focus moves into the panel. Pressing Esc closes it. Screen readers announce it as a dialog with the title you pass. The overlay traps focus so tabbing doesn't escape behind the drawer.

If you build a custom overlay without Radix or a similar headless library, you need to handle all of this yourself: aria-modal, focus trapping, restoring focus to the trigger element on close, and preventing scroll on the body. The headless library route is worth it.

#Adapting components for both contexts

The same content renders in two containers with different space constraints. Rather than building separate component trees, pass a prop that adjusts the chrome:

tsx
// Compact mode for the drawer — smaller heading, no back link
<ToolPageHeader compact />
 
// Fixed FAB for full page, sticky FAB inside scrollable drawer
<ToolFab variant="fixed" />
<ToolFab variant="sticky" />

This keeps business logic in one place. The header, the MDX body, the action bar — all shared. Only the wrapper and sizing change.

#Common pitfalls

default.tsx is not optional. Without it, Next.js doesn't know what to render for @drawer when there's no matching child route. Builds fail with an unhelpful error.

(.) vs (..) depends on where you intercept from. If your list is at /tools and the detail is at /tools/[slug], the interception happens at the same segment level — use (.). If you were intercepting from a parent layout (e.g., the root intercepting /tools/[slug]), you'd use (..).

generateStaticParams works in intercepted routes. Both the full page and the intercepted page can export it. Next.js will pre-render both at build time.

Metadata from intercepted routes is used during soft navigation. If your intercepted page exports generateMetadata, the document title updates when the drawer opens. Keep it aligned with the full page so titles don't flicker on refresh.

#When to use intercepting routes (and when not to)

This pattern works well for list-detail UIs: tool directories, photo galleries, settings panels, notification drawers — anywhere the user should be able to peek at a detail without losing their place in a list, but the detail also needs to stand on its own as a shareable URL.

It does not work well when the detail view has no meaningful list context behind it. If there's nothing useful to show behind the overlay, a standard page transition is simpler and the intercepting route machinery is overhead with no payoff.