kanji-buddy

Next.js by example

The framework explained through this app's own files — what each concept is for, and where it actually bites.

The one idea: the file tree is the routing table

There is no router configuration anywhere in this project. A folder under app/ is a URL segment, and a file called page.tsx makes that segment a page.

app/page.tsx → / app/lessons/page.tsx → /lessons app/levels/[level]/page.tsx → /levels/3 app/subjects/[id]/page.tsx → /subjects/440 app/api/v1/subjects/route.ts → /api/v1/subjects app/layout.tsx → wraps everything

Square brackets make a segment dynamic; app/levels/[level]/page.tsx serves every level. The name inside the brackets is how you read the value back.

FilenameMeaning
page.tsxA page at this URL.
layout.tsxWraps this segment and everything below it. Persists across navigation.
route.tsAn API endpoint. Exports GET, POST, PUT, DELETE by name.
loading.tsxShown while the segment loads. Not used here — the pages handle their own skeletons.
not-found.tsx404 for this segment.

Server and client components

This is the concept that trips people up, and the whole thing rests on one distinction: components render on the server by default. They run once, produce HTML, and ship no JavaScript. They cannot use state, effects or event handlers, because none of that exists on a server.

Putting "use client" at the top of a file opts it — and everything it imports — into the browser. Only then can you use useState, useEffect or onClick.

app/lessons/page.tsx
"use client";

import { useCallback, useEffect, useState } from "react";
// ...state, effects and click handlers below

How to tell which you need

If the component reacts to the user — typing, clicking, a timer — it must be a client component. If it just renders data into markup, leave it on the server and ship less JavaScript. This app is unusual in that nearly every page is a client component, because the data all arrives by fetch after load; the two exceptions are app/unsubscribe/page.tsx and its done page, which are pure markup.

Route handlers: the API lives in the same tree

A route.ts exports functions named after HTTP verbs. They take a standard Request and return a standard Response — Web platform types, not framework ones.

app/api/v1/dashboard/route.ts
export async function GET(request: Request): Promise<Response> {
  const auth = await authenticate(request);
  if (!auth) return unauthorized();

  const data = await buildDashboard(db, auth.userId);
  return serveReport(request, { object: "report", data, /* … */ });
}

A file cannot be both a page and a route — page.tsx and route.ts can’t sit in the same folder. That is why the API lives under app/api/ rather than beside the pages it serves.

Dynamic segments are Promises in Next 15

This changed in Next 15 and is the single most common thing to trip over when following older tutorials. params and searchParams are now asynchronous.

app/subjects/[id]/page.tsx — a client component
export default function SubjectPage({ params }: { params: Promise<{ id: string }> }) {
  const { id: idParam } = use(params);   // React's use(), not await
  const id = Number(idParam);
app/unsubscribe/page.tsx — a server component
export default async function UnsubscribePage({
  searchParams,
}: {
  searchParams: Promise<{ u?: string; t?: string }>;
}) {
  const { u = "", t = "" } = await searchParams;   // plain await

Server components can be async and simply await. Client components cannot, so they unwrap the promise with React’s use() hook. Same value, two mechanisms, decided by which side the component runs on.

useSearchParams needs a Suspense boundary

Reading the query string in a client component uses the useSearchParams hook instead, and Next requires that component to sit inside <Suspense> — otherwise the build fails, because the query string isn’t known at prerender time. The browse pages hit this: components/browse/TypeBrowse reads ?from=&to= and each page wraps it accordingly.

Layouts

app/layout.tsx is the shell every page renders inside. It runs once and survives navigation, so state inside it is not lost when the page changes. This app’s root layout is deliberately thin — the <html> and <body> tags, the stylesheet, and the analytics component. The navigation bar is not in it, because the quiz screens deliberately have no navigation.

Metadata

Exporting a metadata object from a page or layout sets the document head. It is static and read at build time.

app/unsubscribe/page.tsx
export const metadata = {
  title: "Unsubscribe · KanjiBuddy",
  robots: { index: false, follow: false },
};

Static or dynamic, and how to tell

Next decides per route. Anything it can compute at build time becomes static HTML; anything depending on the request stays dynamic. The build output tells you which:

○ (Static) prerendered as static content ƒ (Dynamic) server-rendered on demand ○ /login ← no request-specific input ƒ /subjects/[id] ← the id comes from the URL ƒ /api/v1/subjects ← route handlers are always dynamic

You can force it. app/api/v1/internal/nudges/route.ts declares export const dynamic = "force-dynamic" and export const maxDuration = 60 — the first because a cached cron endpoint would be useless, the second to raise the function timeout.

The bits that actually caused trouble here

  • Module-level database clients. A route file is evaluated once per serverless instance, but db/client.ts resolves lazily through a Proxy on every property access. Caching that only outside production meant every query opened a new connection — and a 31-second dashboard. See Build & deploy.
  • Client components can’t import server-only code. The split between lib/ and lib/web/ exists so that boundary is visible in the import path.
  • Hot reload holds stale module graphs. After a large component is restructured, npm run dev can serve an inconsistent mix until restarted. Worth knowing before debugging a ghost.