Routing

Chapter 2 — File-Based Routing | Next.js Course
Next.js Rebuild Course Chapter 2 of 10

File-Based Routing

Next.js generates your entire URL structure from the folder layout inside app/. No router config files, no annotation scanning — drop a page.tsx in a folder and that URL exists. This chapter maps every URL osztromok.com needs to its exact file path.

🏁

Chapter milestone: The complete route structure for osztromok.com is in place — static pages, dynamic subject/subtopic/page routes, the kanji catch-all, a 404 page, and loading states. Every URL returns either a page or a proper not-found response.

The folder = URL rule

In Next.js App Router, the file system is the router. Each folder inside app/ becomes a URL segment. A file named page.tsx inside that folder makes the route publicly accessible. No other configuration required.

File pathURL it servesNotes
app/page.tsx / Home page
app/about/page.tsx /about Static route
app/japan/page.tsx /japan Subject landing page
app/japan/hiragana/page.tsx /japan/hiragana Subtopic listing page
app/japan/hiragana/basics/page.tsx /japan/hiragana/basics Content page (3-level deep)
app/[subject]/page.tsx /japan   /french   /python  … Dynamic — matches any subject slug
app/[subject]/[subtopic]/[page]/page.tsx /japan/hiragana/basics   /french/verbs/être  … Dynamic 3-level content route
Only page.tsx makes a route public. Other files you put inside an app/ folder — like components/, utils.ts, layout.tsx — are never exposed as URLs. This means you can co-locate component files right next to the routes that use them.

Four ways to name a folder

Folder naming determines how a segment matches URLs. All four types work together and can be nested freely.

folder
Static segment
Matches only that exact string. Use for known, fixed URL parts.
app/japan/ → /japan
app/admin/ → /admin
[folder]
Dynamic segment
Matches any single path segment. The value is available as a param.
app/[subject]/ → /japan, /french, /python
app/[subject]/[subtopic]/ → /japan/kanji
[...folder]
Catch-all segment
Matches one or more segments. Captured as an array. Perfect for deep or variable-length paths.
app/[...slug]/ → /a, /a/b, /a/b/c
app/japan/kanji/[...path]/ → /japan/kanji/水
[[...folder]]
Optional catch-all
Like catch-all but also matches the parent path with zero segments.
app/[[...slug]]/ → /, /a, /a/b …
(less common — usually prefer explicit routes)
(folder)
Route group
Groups routes without affecting the URL. Use to share a layout among a subset of routes.
app/(marketing)/page.tsx → /
app/(admin)/dashboard/page.tsx → /dashboard
_folder
Private folder
Prefix with underscore to opt a folder out of routing entirely. Use for shared components or utilities co-located with routes.
app/_components/Nav.tsx
(never becomes a URL)

Full URL map for osztromok.com

Here is every URL the rebuilt site needs, mapped to its file path. This is the entire routing plan for the course — each chapter fills in more of these files.

URL patternFile path in app/What it shows
/ page.tsx Home — list of subjects
/[subject] [subject]/page.tsx Subject landing — e.g. /japan lists hiragana, kanji, vocab…
/[subject]/[subtopic] [subject]/[subtopic]/page.tsx Subtopic listing — e.g. /japan/kanji lists all kanji pages
/[subject]/[subtopic]/[page] [subject]/[subtopic]/[page]/page.tsx Content page — renders subject+subtopic+page from DB
/japan/kanji/[...char] japan/kanji/[...char]/page.tsx Individual kanji page — char is a Unicode character like 水
/admin (admin)/admin/page.tsx Admin dashboard — protected route group
/admin/subjects (admin)/admin/subjects/page.tsx Manage subjects
/admin/login (admin)/admin/login/page.tsx Login form (not protected)
/api/subjects api/subjects/route.ts REST endpoint — GET returns subject list
Why does the kanji route need [...char]? A Unicode kanji character like 水 is a single path segment — so [char] would technically work. We use [...char] (catch-all) because it also handles multi-segment kanji paths gracefully, and because it avoids a naming collision with the generic [subtopic] above it. The more specific static prefix japan/kanji/ means Next.js always picks this route over the dynamic one.

Building the app/ directory

Create the complete folder structure now. Most page.tsx files will be placeholders — we fill them with real content in later chapters.

Terminal — in the osztromok/ project root
# Dynamic subject/subtopic/page routes
$ mkdir -p "app/[subject]/[subtopic]/[page]"
$ mkdir -p "app/japan/kanji/[...char]"

# Admin route group (parentheses in folder name)
$ mkdir -p "app/(admin)/admin/subjects"
$ mkdir -p "app/(admin)/admin/subtopics"
$ mkdir -p "app/(admin)/admin/pages"
$ mkdir -p "app/(admin)/admin/login"

# API route handlers
$ mkdir -p "app/api/subjects"

After running those commands, your app/ directory should look like this:

app/ ├── layout.tsx ← root layout (from Ch 1) ├── page.tsx ← home page "/" ├── globals.css ├── not-found.tsx ← 404 page (create this chapter) │ ├── [subject]/ ← matches /japan, /french, /python… │ ├── page.tsx ← subject landing (subtopic list) │ ├── loading.tsx ← loading skeleton │ └── [subtopic]/ ← matches /japan/kanji, /japan/vocab… │ ├── page.tsx ← subtopic listing (page list) │ └── [page]/ ← matches /japan/kanji/stroke-order… │ ├── page.tsx ← content page (renders from DB) │ └── loading.tsx ← loading skeleton │ ├── japan/ ← static prefix — takes priority over [subject] │ └── kanji/ │ └── [...char]/ ← catch-all: /japan/kanji/水, /japan/kanji/食… │ └── page.tsx ← individual kanji detail page │ ├── (admin)/ ← route group: URL unaffected, separate layout │ ├── layout.tsx ← admin shell layout (sidebar, auth check) │ └── admin/ │ ├── page.tsx ← /admin dashboard │ ├── login/page.tsx ← /admin/login │ ├── subjects/page.tsx ← /admin/subjects │ ├── subtopics/page.tsx ← /admin/subtopics │ └── pages/page.tsx ← /admin/pages │ └── api/ └── subjects/ └── route.ts ← GET /api/subjects
Static routes take priority over dynamic ones. app/japan/kanji/[...char]/page.tsx will always handle /japan/kanji/水 — Next.js never confuses it with app/[subject]/[subtopic]/[page]/page.tsx because the more specific static path wins. No ordering config required.

Using dynamic segment values

When a dynamic route matches, Next.js passes the captured values as a params prop to your page.tsx. Here's how to read them at each level:

app/[subject]/page.tsx TypeScript · Server Component
// URL: /japan  →  params.subject = "japan"
// URL: /french  →  params.subject = "french"

interface Props {
  params: { subject: string }
}

export default async function SubjectPage({ params }: Props) {
  const { subject } = params   // e.g. "japan"

  // Fetch from DB (Prisma added in Chapter 5 — placeholder for now)
  // const data = await prisma.subject.findUnique({ where: { slug: subject } })

  return (
    <div>
      <h1>Subject: {subject}</h1>
    </div>
  )
}
app/[subject]/[subtopic]/[page]/page.tsx TypeScript · Server Component
// URL: /japan/kanji/stroke-order
// params = { subject: "japan", subtopic: "kanji", page: "stroke-order" }

interface Props {
  params: {
    subject:  string
    subtopic: string
    page:     string
  }
}

export default async function ContentPage({ params }: Props) {
  const { subject, subtopic, page } = params

  return (
    <div>
      <p>{subject} → {subtopic} → {page}</p>
    </div>
  )
}
app/japan/kanji/[...char]/page.tsx TypeScript · Server Component — catch-all
// URL: /japan/kanji/水
// params.char = ["水"]   ← always an array with catch-all

// URL: /japan/kanji/食/detail  (hypothetical deeper path)
// params.char = ["食", "detail"]

interface Props {
  params: { char: string[] }  // array, not string!
}

export default async function KanjiPage({ params }: Props) {
  const character = params.char[0]  // "水"

  // Decode in case the URL is percent-encoded (%E6%B0%B4 → 水)
  const kanji = decodeURIComponent(character)

  return (
    <div>
      <h1 className="text-6xl">{kanji}</h1>
      <p>Kanji detail page</p>
    </div>
  )
}
Kanji characters in URLs: Modern browsers display Unicode characters in the address bar (so you see /japan/kanji/水 not /japan/kanji/%E6%B0%B4), but the actual HTTP request may arrive percent-encoded. Always call decodeURIComponent() before using the value.

Beyond page.tsx — the full special-file list

Next.js recognises several reserved filenames inside app/ folders. Each has a specific role and is automatically wired up with no extra config.

layout.tsx Persistent shell
Wraps all pages in its folder and subfolders. Does not unmount on navigation — state is preserved. Perfect for nav, sidebars, and shared context. Multiple layouts nest automatically.
page.tsx The actual page
Makes the route publicly accessible. Required in every folder you want to serve. Receives params (route segments) and searchParams (query string) as props.
loading.tsx Streaming skeleton
Shown instantly while the page's async data is loading (React Suspense under the hood). No manual Suspense boundaries needed — just create the file and Next.js handles it. Users see your skeleton instead of a blank screen.
not-found.tsx 404 handler
Shown when a route throws notFound() (imported from 'next/navigation') or when no route matches. A root-level not-found.tsx in app/ catches everything.
error.tsx Error boundary
Catches runtime errors thrown inside the route. Must be a Client Component ('use client' at the top). Receives an error object and a reset function to retry.
route.ts API endpoint
Creates an API endpoint instead of a page. Export functions named GET, POST, PUT, DELETE etc. Cannot coexist with page.tsx in the same folder.

Placeholder pages and the 404

Create these now so every route responds correctly. We'll replace the placeholder content with real DB-driven output in Chapters 5 and 6.

app/[subject]/loading.tsx TypeScript · Shown while page data loads
export default function Loading() {
  return (
    <div className="min-h-screen bg-gray-950 flex items-center justify-center">
      <div className="text-gray-500 text-sm animate-pulse">
        Loading…
      </div>
    </div>
  )
}

// Copy this same file to:
// app/[subject]/[subtopic]/[page]/loading.tsx
app/not-found.tsx TypeScript · Global 404 page
import Link from 'next/link'

export default function NotFound() {
  return (
    <div className="min-h-screen bg-gray-950 text-white flex flex-col items-center justify-center gap-4">
      <h1 className="text-6xl font-bold text-gray-700">404</h1>
      <p className="text-gray-400">This page doesn't exist.</p>
      <Link
        href="/"
        className="text-cyan-400 hover:underline text-sm"
      >
        ← Back to home
      </Link>
    </div>
  )
}
app/[subject]/[subtopic]/[page]/error.tsx TypeScript · Must be a Client Component
'use client'   // error boundaries must be client components

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="min-h-screen bg-gray-950 text-white flex flex-col items-center justify-center gap-4">
      <h2 className="text-xl font-bold">Something went wrong.</h2>
      <p className="text-gray-400 text-sm">{error.message}</p>
      <button
        onClick={reset}
        className="text-cyan-400 hover:underline text-sm"
      >
        Try again
      </button>
    </div>
  )
}
app/[subject]/page.tsx (placeholder — expanded in Ch 6) TypeScript · Server Component
import { notFound } from 'next/navigation'

interface Props {
  params: { subject: string }
}

export default async function SubjectPage({ params }: Props) {
  const { subject } = params

  // TODO (Chapter 6): fetch subject from Prisma, call notFound() if null
  // const data = await prisma.subject.findUnique({ where: { slug: subject } })
  // if (!data) notFound()

  return (
    <main className="min-h-screen bg-gray-950 text-white p-8">
      <h1 className="text-3xl font-bold">{subject}</h1>
      <p className="text-gray-400 mt-2">Subject page — content coming in Chapter 6.</p>
    </main>
  )
}

// Metadata — Next.js uses this for <title> and <meta description>
export async function generateMetadata({ params }: Props) {
  return {
    title: params.subject,
  }
}

Throwing a 404 from a page

When a dynamic route matches the URL pattern but the content doesn't exist in the database (e.g. someone visits /japan/wrongslug), you call notFound() from next/navigation. It immediately stops rendering and shows the nearest not-found.tsx.

app/[subject]/[subtopic]/[page]/page.tsx — 404 pattern TypeScript
import { notFound } from 'next/navigation'
// import { prisma } from '@/lib/prisma'  ← Chapter 5

interface Props {
  params: { subject: string; subtopic: string; page: string }
}

export default async function ContentPage({ params }: Props) {
  // Chapter 6 will add the real DB query — pattern shown here:
  // const result = await prisma.page.findFirst({
  //   where: {
  //     slug: params.page,
  //     subtopic: { slug: params.subtopic, subject: { slug: params.subject } },
  //   },
  //   include: { pageContent: { orderBy: { position: 'asc' } } },
  // })
  // if (!result) notFound()   ← this is all it takes to show the 404 page

  return (
    <main className="min-h-screen bg-gray-950 text-white p-8">
      <p className="text-gray-400">
        {params.subject} / {params.subtopic} / {params.page}
      </p>
    </main>
  )
}
notFound() vs throwing an error: use notFound() when the content simply doesn't exist (user typo, deleted page). Use throw new Error() for genuine failures (DB connection down, unexpected data shape) — those show the error.tsx boundary with a retry option.

Metadata API — page titles and descriptions

Next.js handles <title> and <meta> tags through the Metadata API. Export a generateMetadata function from any page.tsx and Next.js automatically sets the right head tags — including for dynamically generated pages.

app/[subject]/[subtopic]/[page]/page.tsx — metadata TypeScript
import type { Metadata } from 'next'

interface Props {
  params: { subject: string; subtopic: string; page: string }
}

// This runs on the server, can do async DB queries
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  // Chapter 6: fetch the real page title from DB
  // const page = await prisma.page.findFirst({ where: { slug: params.page, … } })

  return {
    title: params.page,               // fills %s in layout.tsx template
    description: `${params.subject} — ${params.subtopic}`,
  }
}

export default async function ContentPage({ params }: Props) {
  // page component here…
}
The title.template from Chapter 1 composes automatically. We set template: '%s | osztromok.com' in the root layout. Every child page that returns title: 'Japanese' automatically gets Japanese | osztromok.com in the browser tab. No duplication, no glue code.

Linking between pages — never use <a>

Always use Next.js's <Link> component instead of a plain <a> tag for internal navigation. It prefetches the destination page in the background and performs client-side navigation (no full reload).

Any component — using Link TypeScript
import Link from 'next/link'

export default function SubjectList({ subjects }: { subjects: Subject[] }) {
  return (
    <ul>
      {subjects.map((s) => (
        <li key={s.id}>
          { /* ✓ Use Link for internal routes */ }
          <Link href={`/${s.slug}`}>{s.name}</Link>

          { /* ✗ Don't use plain <a> — causes full page reload */ }
          { /* <a href={`/${s.slug}`}>{s.name}</a> */ }
        </li>
      ))}
    </ul>
  )
}

// For external links, plain <a target="_blank"> is fine:
// <a href="https://jisho.org" target="_blank" rel="noopener">Jisho</a>

Redirecting in code — useRouter and redirect()

Sometimes you need to navigate in code rather than with a link — for example, after a form submission or when an admin isn't logged in.

Server Component / Server Action

Use redirect() from 'next/navigation'. Works in async server code — database lookups, form handlers.

  • redirect('/admin/login') — 307 temporary redirect
  • permanentRedirect('/new-url') — 308 permanent
  • Can be called from Server Actions after successful form submit
  • Throws internally — no return needed after calling it

Client Component

Use the useRouter() hook from 'next/navigation'. Works in event handlers and effects.

  • router.push('/dashboard') — navigate + add to history
  • router.replace('/login') — navigate, replace current history entry
  • router.back() — browser back button equivalent
  • Component must have 'use client' at the top
Server-side redirect example (used in Chapter 8 for auth) TypeScript · Server Component
import { redirect } from 'next/navigation'
import { getServerSession } from 'next-auth/next'

export default async function AdminPage() {
  const session = await getServerSession()

  if (!session) {
    redirect('/admin/login')   // ← throws internally, nothing returned
  }

  return <div>Admin dashboard</div>
}

Verify your routes are working

With npm run dev running, open each of these URLs and confirm you get a page (not a 404 from Next.js itself):

http://localhost:3000/

Home page from app/page.tsx — your placeholder from Chapter 1.

http://localhost:3000/japan

Matches app/[subject]/page.tsx — shows "japan" in the heading. Try /french and /python — they all work with the same file.

http://localhost:3000/japan/kanji/stroke-order

Matches app/[subject]/[subtopic]/[page]/page.tsx — shows all three segment values.

http://localhost:3000/japan/kanji/水

Matches app/japan/kanji/[...char]/page.tsx — shows the kanji character. This confirms the static prefix takes priority over the dynamic route above it.

http://localhost:3000/this-does-not-exist

No page.tsx matches — shows your custom app/not-found.tsx with the 404 message and home link.

If /japan/kanji/水 shows the wrong page: check that app/japan/kanji/[...char]/page.tsx exists and that the folder names are exactly right — including the ... prefix inside the brackets. The catch-all brackets must be [...char], not [char].