A folder is a URL
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.
| Folder | Matches | params |
|---|---|---|
doors | Exactly /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
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
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.
// 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.
/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
Navigating
<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.
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.
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