Skip to main content
CodeOath
← All posts

Next.js75 min total · 12 parts

Next.js Fundamentals: The App Router, Server Components, and the Caching Rules That Just Flipped

Part 5 of 12 · ~3 min

Routing & Layouts

The App Router turns your file tree into the route table — nothing to maintain in a separate config anywhere. Nest a folder and you've defined a URL segment matching its name; drop a page.tsx inside that folder and the segment becomes something a visitor can actually land on:

app/
  page.tsx                       → /
  jobs/
    [slug]/
      page.tsx                   → /jobs/senior-backend-engineer-remote
  dashboard/
    layout.tsx
    page.tsx                     → /dashboard
    applicants/
      page.tsx                   → /dashboard/applicants

A bare folder isn't a route on its own — it only becomes one once a page.tsx shows up inside it, and that's a feature rather than a limitation: you can drop helper components, tests, or utilities that only make sense for one route right next to it, without any of them accidentally becoming a URL.

What nesting a layout actually buys you

layout.tsx wraps every page.tsx beneath it — and any layouts nested further in — taking whatever it wraps as a children prop. OpenRoles needs exactly this for the employer dashboard: a sidebar linking out to jobs, applicants, and settings, present on every page under /dashboard:

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="dashboard-shell">
      <DashboardSidebar />
      <main>{children}</main>
    </div>
  );
}

Every route beneath app/dashboard/ picks this shell up automatically — no page has to import it, no page has to remember it exists.

Here's the part that's easy to undersell: writing the sidebar once is the small win. The bigger one is what survives a click from /dashboard to /dashboard/applicants — DashboardSidebar doesn't get unmounted and rebuilt just because the URL underneath it changed. It's the same component instance the whole time, so if it's tracking, say, which nav section is expanded in its own state, that state rides through the navigation untouched, and anything it already fetched doesn't get fetched again for no reason. Only the part of the screen that's genuinely different — the page.tsx content — actually re-renders. A plain client-rendered app doesn't get any of that for free — every navigation means rebuilding the page from nothing, sidebar included. Here, a live "3 new applicants" counter sitting in DashboardSidebar just keeps counting straight through navigation, with nothing special written for it, because it was never taken apart to begin with.

Segments that capture part of the URL

Square brackets around a folder name — [slug] — turn that position into a placeholder: whatever a visitor actually typed there gets handed into the page as a param. One wrinkle specific to Next.js 16: that param now arrives as a Promise, not a plain object, which you can already see above in the job page's params: Promise<{ slug: string }> followed by const { slug } = await params;. Chapter 5 circles back to exactly why that changed.

OpenRoles also wants candidates to browse by category and seniority — everything, or just remote, or remote narrowed further to senior. A catch-all segment, [...category], would grab one or more remaining path pieces as an array, but it would refuse to match the bare /jobs/category URL with nothing after it. Adding a second pair of brackets — [[...category]] — makes those extra segments optional, so the same route also matches with zero of them:

// app/jobs/category/[[...category]]/page.tsx
export default async function CategoryPage({
  params,
}: {
  params: Promise<{ category?: string[] }>;
}) {
  const { category } = await params;
  // /jobs/category                 → category is undefined       → show everything
  // /jobs/category/remote          → category is ["remote"]
  // /jobs/category/remote/senior   → category is ["remote", "senior"]
  const filters = category ?? [];
  const jobs = await getJobsByFilters(filters);
  return <JobsList jobs={jobs} />;
}

One file covers a browse page with no filter, one filter, or two — no separate page.tsx per combination.

Segment syntaxMatchesExample
[slug]Exactly one segment/jobs/senior-backend-engineer-remote → { slug: "senior-backend-engineer-remote" }
[...category]One or more segments/jobs/category/remote/senior → { category: ["remote", "senior"] }
[[...category]]Zero or more segments/jobs/category → { category: undefined }