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.tsxbecomes/docs [slug]in a folder name captures that segment intoparams[...slug]captures 1+ segments as an array;[[...slug]]captures 0+ (optional)- Route groups
(name)organize files without touching the URL - A folder without
page.tsxcreates no route, it only organizes code
Quick example
// 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:
// 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:
// 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// 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 rootOn 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
| File | Purpose |
|---|---|
page.tsx | Page UI (makes the segment accessible as a route) |
layout.tsx | Shared layout, persists across navigations |
loading.tsx | Suspense fallback during data fetching |
error.tsx | Error boundary for the segment |
not-found.tsx | Custom 404 for the segment |
template.tsx | Like layout but re-mounts on every navigation |
route.ts | API 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
// 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
// app/[...userId]/page.tsx
// /profile/123 → params.userId = ['profile', '123'] — captures too much
// Fix: app/profile/[userId]/page.tsxCatch-all grabs everything in the path, including segments you didn't intend to capture.
Mistake 3: Forgetting generateStaticParams for dynamic routes
// 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]/apifor multi-product documentation - IT Lead: intercepting routes for problem modal previews
- CMS-backed sites:
generateStaticParamspre-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
// 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
// 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
// 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 readyA concise answer to help you respond confidently on this topic during an interview.