Skip to main content

Routing in Next.js (file-based routing)

File-based routing in Next.js maps the app directory's folder structure directly to URL paths. Each page.tsx file defines a route. No router config, no <Route> declarations, no imports from react-router.

Theory

TL;DR

  • Folder name = URL segment; app/docs/page.tsx becomes /docs
  • [slug] in a folder name captures that segment into params
  • [...slug] captures 1+ segments as an array; [[...slug]] captures 0+ (optional)
  • Route groups (name) organize files without touching the URL
  • A folder without page.tsx creates no route, it only organizes code

Quick example

tsx
// app/page.tsx → URL: / export default function Home() { return <h1>Home</h1> } // app/blog/page.tsx → URL: /blog export default function Blog() { return <h1>Blog</h1> } // app/blog/[slug]/page.tsx → URL: /blog/my-first-post export default function Post({ params }: { params: { slug: string } }) { return <h1>Post: {params.slug}</h1> // "my-first-post" }

Next.js scans app/ at build time and generates a route manifest from the folder structure. That manifest is what the server uses to match incoming requests.

How it works internally

Next.js (via Turbopack or webpack) walks the app directory recursively at build time. Each page.tsx becomes a route segment based on its folder path. Dynamic segments like [slug] become regex patterns in the route manifest (build-manifest.json). At request time, the server matches the URL against those patterns and extracts params.

The params object is injected as a prop through React Server Components, so it is available server-side by default. On client components, you use useParams() from next/navigation instead. Mixing these up is the most common bug I see in code reviews when a server component gets refactored into a client one and nobody updates the params access.

Dynamic routes

Square brackets mark a folder as a dynamic segment:

tsx
// app/shop/[category]/page.tsx → /shop/laptops, /shop/phones export default async function Category({ params }: { params: { category: string } }) { const products = await getProducts(params.category) return <h1>{params.category}</h1> }

Nesting works the same way. app/shop/[category]/[id]/page.tsx gives you both params.category and params.id at /shop/laptops/123.

Catch-all routes

[...slug] captures one or more segments as an array:

tsx
// app/docs/[...slug]/page.tsx // /docs/react → slug = ['react'] // /docs/react/hooks → slug = ['react', 'hooks'] export default function Docs({ params }: { params: { slug: string[] } }) { return <div>{params.slug.join(' / ')}</div> }

[[...slug]] (double brackets) also matches the root path. So app/docs/[[...slug]]/page.tsx handles /docs (empty array) and every deeper path. Single [...slug] requires at least one segment. That is the only difference, but it causes a lot of 404s when building docs indexes.

Route groups

Parentheses create a grouping folder that does not appear in the URL:

app/ (marketing)/ layout.tsx <- marketing layout about/page.tsx -> /about pricing/page.tsx -> /pricing (platform)/ layout.tsx <- platform layout dashboard/page.tsx -> /dashboard

(marketing) and (platform) never show up in the URL. Each group can have its own layout.tsx, so /about and /dashboard use completely different layouts while living in the same app/ directory.

Parallel routes

Parallel routes render multiple independent sections inside one layout. Slots are defined with @:

app/dashboard/ layout.tsx page.tsx @stats/page.tsx @activity/page.tsx
tsx
// app/dashboard/layout.tsx export default function DashboardLayout({ children, stats, activity }: { children: React.ReactNode stats: React.ReactNode activity: React.ReactNode }) { return ( <div> {children} <div className="grid grid-cols-2 gap-4"> {stats} {activity} </div> </div> ) }

Each slot loads and streams independently. @stats can show a loading state while @activity is already rendered.

Intercepting routes

Intercepting routes let you show a route in a different context without changing the URL. The standard use case: clicking a photo opens a modal, but navigating directly to /photos/123 shows the full photo page.

(.) — intercept at the same level (..) — one level up (..)(..) — two levels up (...) — from the app root

On IT Lead, clicking a problem card opens a modal preview. Direct URL access shows the full problem page. That is intercepting routes in practice.

Special files

FilePurpose
page.tsxPage UI (makes the segment accessible as a route)
layout.tsxShared layout, persists across navigations
loading.tsxSuspense fallback during data fetching
error.tsxError boundary for the segment
not-found.tsxCustom 404 for the segment
template.tsxLike layout but re-mounts on every navigation
route.tsAPI endpoint (Route Handler, App Router only)

A folder without page.tsx creates no route. It only organizes code (colocation). Next.js registers a segment only where it finds a page.tsx.

Common mistakes

Mistake 1: Accessing params directly in a client component

tsx
// WRONG 'use client' export default function Page({ params }: { params: { slug: string } }) { return <div>{params.slug}</div> // undefined on client } // CORRECT 'use client' import { useParams } from 'next/navigation' export default function Page() { const params = useParams() // works client-side return <div>{params.slug as string}</div> }

params as a prop only works in Server Components. Client components need useParams().

Mistake 2: Using [...slug] when a single segment is enough

tsx
// app/[...userId]/page.tsx // /profile/123 → params.userId = ['profile', '123'] — captures too much // Fix: app/profile/[userId]/page.tsx

Catch-all grabs everything in the path, including segments you didn't intend to capture.

Mistake 3: Forgetting generateStaticParams for dynamic routes

tsx
// Without this, /blog/[slug] is server-rendered on every request → slow TTFB export async function generateStaticParams() { const posts = await getPosts() return posts.map((post) => ({ slug: post.slug })) }

If you know your slugs at build time, export generateStaticParams. Next.js pre-renders those pages as static HTML.

Mistake 4: Confusing [...slug] and [[...slug]]

[...slug] at /docs (no segments) returns a 404. [[...slug]] at /docs matches and gives params.slug = []. When your docs index page also needs to match, use double brackets.

Real-world usage

  • Next.js blog: /blog/[slug] fetches MDX posts from the filesystem
  • Vercel docs: /docs/[product]/[version]/api for multi-product documentation
  • IT Lead: intercepting routes for problem modal previews
  • CMS-backed sites: generateStaticParams pre-builds pages from API at deploy time
  • Linear-style apps: [...id] catch-all for deep-linked issue URLs

Follow-up questions

Q: How does [...slug] differ from [[...slug]]?
A: [...slug] requires at least one segment. A request to /docs with no further path will 404. [[...slug]] makes all segments optional, so /docs matches and returns an empty array.

Q: What happens at /docs/a/b when the file is app/docs/[slug]/page.tsx?
A: 404. A single [slug] captures exactly one segment. Multi-segment paths need [...slug].

Q: How do params and searchParams differ?
A: params comes from the URL path (/post/123 gives { id: '123' }). searchParams comes from the query string (?draft=true gives { draft: 'true' }). Both are available as props in Server Components.

Q: How do you pre-build dynamic routes as static pages?
A: Export generateStaticParams() returning an array of param objects. Next.js runs this at build time and generates one static HTML file per entry.

Q: (Senior) Middleware intercepts /[slug] and rewrites the URL. Do params still work in the page component?
A: Yes. Use NextResponse.rewrite(new URL('/real-path', req.url)) in middleware. The rewrite changes which file handles the request, but params are extracted from the URL pattern of the matched file, so they stay intact.

Examples

Basic route structure

tsx
// app/page.tsx → / export default function Home() { return <h1>Home</h1> } // app/problems/page.tsx → /problems export default function Problems() { return <h1>Problems</h1> } // app/problems/[id]/page.tsx → /problems/fizzbuzz export default function Problem({ params }: { params: { id: string } }) { return <h1>Problem: {params.id}</h1> }

Folder name becomes URL segment. page.tsx makes it accessible. That is the whole system.

Blog post loader with notFound

tsx
// app/blog/[slug]/page.tsx import { notFound } from 'next/navigation' async function getPost(slug: string) { const res = await fetch(`https://api.example.com/posts/${slug}`, { cache: 'force-cache' // cached at build time or via ISR }) if (!res.ok) notFound() // triggers the nearest not-found.tsx return res.json() } export default async function Post({ params }: { params: { slug: string } }) { const post = await getPost(params.slug) return ( <article> <h1>{post.title}</h1> <div dangerouslySetInnerHTML={{ __html: post.content }} /> </article> ) } // Pre-build known slugs at deploy time export async function generateStaticParams() { const posts = await getPosts() return posts.map((p) => ({ slug: p.slug })) }

notFound() from next/navigation triggers the nearest not-found.tsx. generateStaticParams tells Next.js which slugs to pre-render as static HTML, which removes per-request server rendering and improves TTFB.

Catch-all docs route with optional root match

tsx
// app/docs/[[...slug]]/page.tsx // Matches: /docs, /docs/react, /docs/react/hooks/useState export default function Docs({ params }: { params: { slug?: string[] } }) { if (!params.slug || params.slug.length === 0) { return <h1>Docs Home</h1> } const path = params.slug.join('/') // /docs/react/hooks → path = "react/hooks" return <div>Doc: /{path}</div> }

Double brackets [[...slug]] make all segments optional. One file handles the docs index page and every nested path below it. With single brackets [...slug], the index would 404.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?