Skip to content
beginner Phase 2 · App Router, Layouts, and Pages

App Router Fundamentals

Turn folders into URL segments without route configuration files.

45m
0 problems
Topic Progress 0%

How /blog/hello-world Becomes a Component: Segment Matching and async params

There is no routes file. No array of paths, no decorator, no table in a config — you create a folder, put page.tsx in it, and the URL exists. The mechanism is segment matching: every directory level under app/ contributes exactly one URL segment, and the file that supplies the UI is the page inside the deepest matching folder.

app/
├── page.tsx                      →  /
├── pricing/page.tsx              →  /pricing
├── blog/
│   ├── page.tsx                  →  /blog
│   ├── first-post/page.tsx       →  /blog/first-post     (static beats dynamic)
│   └── [slug]/page.tsx           →  /blog/<anything-else>
├── docs/[...slug]/page.tsx       →  /docs/a  /docs/a/b       (required catch-all)
└── shop/[[...slug]]/page.tsx     →  /shop  /shop/a/b         (optional catch-all)

When a request for /blog/first-post arrives, Next walks the tree and prefers the static folder over [slug]; /blog/some-new-post falls through to the dynamic one. The general precedence is static → dynamic [x] → catch-all [...x] → optional catch-all [[...x]], which is why a catch-all can never shadow a concrete page — and why two folders that resolve to the same path (for example (a)/pricing and (b)/pricing) are a build error rather than a coin flip.

Note what a folder full of non-page files does: app/dashboard/layout.tsx with no page.tsx inside still produces no URL — visiting /dashboard returns 404, because the layout has nothing to wrap. Special files decorate a route; they never create one. And the matched params are not exclusive to the page: layout.tsx, template.tsx, loading.tsx, generateMetadata, and generateStaticParams in the same segment all receive the same params, so a tab strip can highlight the active slug without re-fetching anything.

The App Router's big version-specific change arrived with Next.js 15: params and searchParams are now Promises. The old synchronous params.slug was removed from the type surface, and reading it without awaiting yields an undefined that silently renders the wrong content:

// app/blog/[slug]/page.tsx — Next 15 shape
export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params          // ← the await is not optional
  const post = await getPost(slug)
  if (!post) notFound()
  return <article>{post.title}</article>
}

Skip the await and TypeScript complains (Property 'slug' does not exist on type 'Promise<{ slug: string }>'), or — in a JavaScript project with no types — you fetch /posts/undefined and ship a blank page. In a Client Component you never touch the Promise: useParams() from next/navigation returns the values directly.

Why async at all? Because the framework now needs to know when request data is resolved before it streams the rest of the tree; making params/searchParams/cookies()/headers() all Promises lets Next start rendering the shell before user-specific data settles. It is the same reasoning behind Suspense boundaries — streaming rather than blocking.

Versus the Pages Router, the comparison interviewers ask for:

Pages Router (pages/) App Router (app/)
Route declaration one file per URL one folder per segment + page
Data fetching getServerSideProps / getStaticProps async component, direct await
Layouts _app.tsx wrapper, remounts nested layout.tsx, persists
API endpoints pages/api/*.ts route.ts anywhere in app/
Rendering default static or SSR per page Server Components by default

Config decides what the server does — redirects, image hosts, headers — and stops short of routing, which is why the handoff from next.config Options lands here: folders, not functions, are how the App Router declares URLs.

page, route, and Segment Config: Deciding How Each URL Renders

Two files define behavior at a URL: page.tsx renders it for a human, route.ts answers it for a machine. A segment picks one. The page receives params and searchParams; the route exports HTTP methods:

// app/api/orders/route.ts
export async function GET(request: Request) {
  const orders = await db.order.findMany()
  return Response.json(orders)
}
export async function POST(request: Request) {
  const body = await request.json()
  // …
  return Response.json({ id: 'ord_123' }, { status: 201 })
}

One Next 15 behavior worth memorizing: GET Route Handlers are no longer cached by default (as of v14 they were) — every GET hits your code unless you opt in with export const revalidate = 60 or cache: 'force-cache' in the fetch. The same de-caching applied to fetch() itself: v15 flipped the default from force-cache to no-store, so data is fresh by default and staleness is now something you choose rather than remove.

Route Handlers get the same async treatment in Next 15 — the context object hands you params as a Promise, and you read a body with the standard Request API rather than a framework helper:

export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
  const { id } = await ctx.params
  return Response.json(await db.order.find(id))
}

Pages also export generateMetadata, which runs alongside the page and receives the same params — it is how per-route titles and Open Graph images are produced without a client-side document.title hack:

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await getPost(slug)
  return { title: post.title, alternates: { canonical: `/blog/${slug}` } }
}

The bigger decision per route is static vs dynamic, controlled by exports at the top of the file (the "route segment config"):

export const dynamic = 'force-dynamic'   // 'auto' | 'force-static' | 'error'
export const revalidate = 60             // ISR: regenerate at most every 60s
export const runtime = 'nodejs'          // or 'edge'
export const dynamicParams = true        // unlisted params: render (true) or 404 (false)
export const maxDuration = 60            // ceiling for one invocation on serverless

With no config at all, Next prerenders the route at build time — the ○ in your build output — as long as nothing touches request-specific data. The things that force server-side rendering on demand:

  • cookies() / headers() / connection() (all async in Next 15)
  • reading searchParams in a page
  • dynamic = 'force-dynamic'
  • a fetch marked cache: 'no-store'
// Static candidate: no request data touched
export default async function AboutPage() {
  const { count } = await db.product.count()   // runs at build; ISR via `revalidate`
  return <p>{count} products</p>
}

// Dynamic: per-request by nature
export default async function CartPage({ searchParams }: {
  searchParams: Promise<{ coupon?: string }>
}) {
  const { coupon } = await searchParams       // user input → cannot be prerendered
  return <Cart coupon={coupon} />
}

runtime = 'edge' deserves a deliberate choice: edge invocations start faster and run close to the user, but they cannot load native modules (bcrypt, many DB drivers) and run in a restricted V8-derived environment — a Postgres driver simply fails there. Default to nodejs and switch only when you have a measured reason.

For interviews, "when is a route static?" is the same question with three correct triggers: no request APIs are read, no explicit dynamic opt-out, and any data call is cacheable — add one member from the list above and the route becomes dynamic. The mechanism that keeps dynamic segments sane when only some paths are prebuilt is generateStaticParams plus dynamicParams, and that is exactly where routes start 404-ing — the subject of the next chapter, with Root Layouts following it as the shell these pages render inside.

When a Route 404s: Params, Case Sensitivity, and Shadowed Paths

Most App Router debugging is a route that exists in your editor and not in production. Four causes account for nearly all of it.

1. Unlisted params with dynamicParams: false. Pre-rendering a dynamic route works in two halves: generateStaticParams() declares the paths to build, and dynamicParams decides what happens for everything else.

// app/products/[id]/page.tsx
export async function generateStaticParams() {
  return [{ id: 'sku-1' }, { id: 'sku-2' }]     // prerendered at build
}
export const dynamicParams = false               // any other id → 404

Default is true: unknown ids render on demand, per request. Flip it to false — a legitimate choice for a small catalog or an output: 'export' deployment — and every id you forgot to list returns 404, including the one the marketing team added this morning. The failure looks like missing data but is really a routing policy.

2. Case sensitivity. app/About/page.tsx and app/about/page.tsx are the same folder on Windows and macOS (case-insensitive filesystems) and two different folders on Linux. A project developed on Windows happily runs with Page.tsx instead of page.tsx, then returns 404 for the entire route once the build runs on a case-sensitive CI runner — and, worse, the preview environment that also runs Linux was broken from day one and nobody looked. Route files are lowercase by convention for this reason: page.tsx, layout.tsx, route.ts. Rename before the first deploy, not after the incident.

3. Duplicate or shadowed paths. Two route groups producing the same URL ((marketing)/pricing/page.tsx and (app)/pricing/page.tsx) cannot both win; the build flags the duplicate. A static folder also shadows a dynamic sibling (blog/first-post/ in front of blog/[slug]/) — brilliant when intended, baffling when a leftover test folder silently eats traffic from a real post.

4. Wrong tree level. The URL follows folders from app/, so app/(shop)/checkout/page.tsx is /checkout, while a stray extra directory — app/pages/checkout/page.tsx — is /pages/checkout. Both are visible in one glance at the build's route table, which is the fastest diagnostic there is:

Route (app)                         Size     First Load JS
┌ ● /blog/[slug]                    1.2 kB   94.5 kB

Not-found and error are different responses. notFound() throws a special error that not-found.tsx renders with a real 404 status — use it when the record does not exist. A crashed fetch or a thrown exception renders error.tsx with a 500. Serving a 200 page for a missing record is the SEO version of lying to your users: crawlers index dead links and keep sending traffic that bounces. Throw notFound() after your lookup misses, every time.

There is also a URL-shape cause worth ruling out cheaply: trailing slashes. If trailingSlash: true is set in next.config.ts but your links are built as /about/ versus /about, one form redirects and, behind certain CDNs that cache both variants, one can 404. Check curl -I on the failing URL before touching the folder tree — it distinguishes a routing problem from a URL-format problem in seconds.

The debugging order I would state in an interview: confirm a page.tsx exists at the segment path, check spelling including case, check precedence (static vs dynamic vs catch-all), then check dynamicParams, then check for duplicates introduced by route groups. If all of that is clean, the route probably is not missing at all — it is being redirected or rewritten by next.config.ts, which is why next.config Options is the right place to look before you start renaming folders. notFound() plus a colocated not-found.tsx is how a route reports "that record does not exist" with a real 404 status rather than a broken page. Once URLs are behaving, the next piece is the shell every page renders inside: Root Layouts.

Quiz

1. The tree contains both `app/blog/first-post/page.tsx` and `app/blog/[slug]/page.tsx`. Which URL matches the second file?

Question 1 options

2. In Next.js 15, a page does `const slug = params.slug` without awaiting. What happens?

Question 2 options

3. Which of these makes a page render per request instead of at build time?

Question 3 options

4. A route works on a developer's Windows laptop and returns 404 in the Linux CI environment. What is the likeliest cause?

Question 4 options

Flashcards

Question

What is the precedence order when App Router folders can match the same URL?

Answer

Static segment > [dynamic] > [...catch-all] > [[...optional catch-all]]. Two folders resolving to the exact same URL (typically two route groups) is a build error rather than an arbitrary winner.

Question

Why are params and searchParams Promises in Next 15, and how do client components read them?

Answer

They became async so the framework can begin streaming the shell before request-specific data resolves, consistent with cookies() and headers(). Server components await them; client components use useParams() / useSearchParams() from next/navigation, which return values synchronously.

Question

List the APIs and exports that force a route to render per request.

Answer

cookies(), headers(), connection(), reading searchParams, `export const dynamic = 'force-dynamic'`, and a fetch with `cache: 'no-store'`. Absent all of these, Next prerenders the route at build time (the ○ in the route table).

Question

What do generateStaticParams and dynamicParams control together?

Answer

generateStaticParams enumerates the paths to prerender at build for a dynamic segment; dynamicParams decides the rest — true (default) renders unknown params on demand, false returns 404 for any id not listed.

Question

What changed for caching between Next 14 and Next 15?

Answer

fetch() flipped from force-cache to no-store by default, and GET Route Handlers stopped being cached — data is fresh unless you opt in with cache: 'force-cache', `next: { revalidate }`, or `export const revalidate`.

Revision Notes

Key Takeaways

  • 1. Routing is folder-based segment matching with no routes file: the deepest matching folder's page.tsx renders, and static folders beat dynamic ones beat catch-alls
  • 2. Next 15 made params/searchParams/cookies/headers async — awaiting is mandatory, and skipping it yields undefined that silently renders the wrong content
  • 3. Static vs dynamic is decided by request APIs: no cookies, headers, or searchParams means the route prerenders and serves with zero server work per request
  • 4. v15 de-cached fetch and GET route handlers, so freshness became something you opt into rather than something you fight
  • 5. A 404 is a diagnostic sequence: page.tsx present, exact lowercase name, precedence, dynamicParams, duplicate group paths, then next.config redirects

Interview Tips

  • • "How does the App Router differ from the Pages Router?" — folder-per-segment vs file-per-URL, async components vs getServerSideProps, nested persistent layouts vs _app.tsx, route.ts vs pages/api; finish with server rendering by default
  • • "When is a route static?" — no request APIs touched, no dynamic opt-out, cacheable data only; then list the dynamic triggers (cookies, headers, searchParams, force-dynamic, no-store) so the answer has both halves
  • • "Why did Next 15 make params async?" — streaming: the shell can render before user-specific data settles, and it unifies every request API under one await model; mention the silent undefined failure as the migration gotcha
  • • "A URL 404s in production but works locally — what do you check?" — page.tsx presence, file-name case (Windows/macOS vs Linux), precedence and shadowing, dynamicParams false with an unlisted id, then redirects/rewrites in next.config

Cheat Sheet

App Router Fundamentals

Segment matching: every folder under app/ = one URL segment; the page.tsx in the deepest match renders it.

app/page.tsx                →  /
app/pricing/page.tsx        →  /pricing
app/blog/[slug]/page.tsx    →  /blog/hello-world
app/docs/[...slug]/page.tsx →  /docs/a, /docs/a/b
app/shop/[[...slug]]/page.tsx → /shop, /shop/a/b

Precedence: static folder → [dynamic] → [...catch-all] → [[...optional]]. Two folders resolving to the same URL = build error.

Next 15 (async request APIs):

export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params        // awaiting is mandatory
}
  • Skipping await → undefined → fetches /posts/undefined; TS errors on Promise<{slug: string}>.
  • Client side: useParams() returns values synchronously (no Promise).
  • Same async rule applies to searchParams, cookies(), headers().

page vs route:

File Role
page.tsx UI for the URL (params, searchParams props)
route.ts Endpoint exporting GET/POST/… — GET handlers are not cached by default in v15

Route segment config:

export const dynamic = 'force-dynamic'  // 'auto' | 'force-static' | 'error'
export const revalidate = 60            // ISR
export const runtime = 'nodejs'         // 'edge' = no native modules / DB drivers
export const dynamicParams = true       // false → 404 for unlisted params

Forces dynamic rendering: cookies() · headers() · searchParams · force-dynamic · cache: 'no-store'. Note: v15 changed fetch's default from force-cache to no-store, and v14→v15 also stopped caching GET route handlers.

404 triage order:

  1. page.tsx present at the segment path?
  2. Exact lowercase match (Page.tsx works on Windows/macOS, 404s on Linux CI)?
  3. Precedence — is a static folder or group shadowing it?
  4. dynamicParams: false and the id wasn't in generateStaticParams()?
  5. Duplicate URL from two route groups?
  6. Redirect/rewrite in next.config.ts intercepting it?

Pages vs App Router: file-per-URL vs folder-per-segment · getServerSideProps vs async component · _app.tsx vs nested persistent layout.tsx · pages/api vs route.ts.

Continue Learning

Before You Start

Next Topics

Related Reading