Routing

Read a URL out of a folder structure, and know which route wins when several could match.

03of 16
≈ 12Min read
7Parts

A folder is a URL

the mapping, in full
app/page.tsx                    →  /
app/doors/page.tsx              →  /doors
app/doors/[id]/page.tsx         →  /doors/12          params: { id: "12" }
app/doors/[id]/specs/page.tsx   →  /doors/12/specs
app/blog/[...slug]/page.tsx     →  /blog/a/b/c        params: { slug: ["a","b","c"] }
app/shop/[[...path]]/page.tsx   →  /shop AND /shop/a/b
app/(marketing)/about/page.tsx  →  /about             the group is not in the URL
app/_components/Card.tsx        →  not a route at all

The four kinds of segment

A catch-all matches greedily but never matches nothing; the optional form is the one that also answers the bare parent URL.

FolderMatchesparams
doorsExactly /doors—
[id]One segment{ id: "12" }
[...slug]One or more segments{ slug: ["a", "b"] }
[[...slug]]Zero or more{} or { slug: [...] }

Folders that are not URLs

two conventions worth knowing on day one
app/(marketing)/about/page.tsx    // (group) — organise files, and share a layout,
app/(marketing)/layout.tsx        // without adding a path segment
app/(app)/dashboard/page.tsx

app/_lib/db.ts                    // _private — never a route, whatever is in it
app/doors/_components/Card.tsx    // components next to the route that uses them
Two groups cannot produce the same URL

app/(a)/about/page.tsx and app/(b)/about/page.tsx both resolve to /about, and the build fails. Groups change the layout, never the path.

Reading the parameters

searchParams is only available in a page, and reading it makes the route dynamic — a query string cannot be known at build time.

they arrive as props, and they are promises in Next 15
// app/doors/[id]/page.tsx
export default async function DoorPage({ params, searchParams }) {
  const { id } = await params;              // Next 15: params is a Promise
  const { q } = await searchParams;

  const door = await getDoor(id);
  if (!door) notFound();                    // renders the nearest not-found.tsx

  return <h1>{door.name}</h1>;
}

Which route wins

This is why a literal new or settings folder beside a [id] folder works exactly as you would hope, and why you never have to think about the order files are read in.

most specific first, always
/doors/new     matches both:
app/doors/new/page.tsx       ← static wins
app/doors/[id]/page.tsx

// the order is: static → dynamic → catch-all → optional catch-all
  • <Link> prefetches the route when it scrolls into view, which is why navigation feels instant.
  • A plain <a> reloads the whole application. Use it only for external links.
  • The hooks come from next/navigation, and only work in client components.
Link, and the router
import Link from "next/link";
<Link href="/doors/12">Painted</Link>
<Link href={{ pathname: "/doors", query: { q: "oak" } }}>Search</Link>

"use client";
import { useRouter, usePathname, useParams, useSearchParams } from "next/navigation";

const router = useRouter();
router.push("/doors");
router.replace("/login");        // no history entry
router.refresh();                // re-fetch the server components of this route

Parallel and intercepting routes

These make "open it in a modal, but the URL is real and shareable" possible without a state machine. They are worth knowing exist, and not worth reaching for on day one.

the two advanced ones, in one screen
app/@modal/…       // a named slot the layout renders alongside children
app/(.)doors/[id]  // intercept /doors/12 — show it as a modal over the list,
                   // while a direct visit or a reload renders the real page

Practice