Routing
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 path | URL it serves | Notes |
|---|---|---|
| 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 |
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.
app/admin/ → /admin
app/[subject]/[subtopic]/ → /japan/kanji
app/japan/kanji/[...path]/ → /japan/kanji/水
(less common — usually prefer explicit routes)
app/(admin)/dashboard/page.tsx → /dashboard
(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 pattern | File 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 |
[...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.
# 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/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:
// 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>
)
}
// 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>
)
}
// 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>
)
}
/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.
params (route segments) and searchParams
(query string) as props.
notFound() (imported from 'next/navigation')
or when no route matches. A root-level not-found.tsx in
app/ catches everything.
'use client' at the top).
Receives an error object and a reset function to retry.
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.
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
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>
)
}
'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>
)
}
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.
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() 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.
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…
}
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).
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 redirectpermanentRedirect('/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 historyrouter.replace('/login')— navigate, replace current history entryrouter.back()— browser back button equivalent- Component must have
'use client'at the top
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.
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].