Module 5 · Next.js · Deep Dive
You have a FastAPI backend. Now you need a frontend that renders on the server, talks to that API, and keeps secrets server-side — exactly what Next.js was built for.
BasicIntermediateBuild
Plain React (Create React App, Vite) ships an empty <div> and a bundle of JavaScript. The browser downloads it, runs it, then fetches your data and draws the page. That's slow to first paint, invisible to search engines, and forces every API key into the browser. Next.js fixes all three:
echo.
Start a new project the official way:
# scaffolds an App Router project with TypeScript npx create-next-app@latest docchat-web cd docchat-web npm run dev # http://localhost:3000
Accept the defaults (TypeScript, App Router, Tailwind if you like). You get a working app in one command.
app/ directoryEverything lives in app/. The structure of folders and a few special filenames is the routing system — there's no separate route table to maintain.
| File | Role |
|---|---|
page.tsx | Makes a folder a route. app/about/page.tsx → /about. |
layout.tsx | Wraps pages below it — shared header, nav, sidebar. |
loading.tsx | Shown automatically while the page is loading. |
error.tsx | Shown automatically if the page throws. |
[id] | A dynamic folder — matches any value, passed as a param. |
app/ layout.tsx # root layout — wraps every page page.tsx # the route "/" documents/ page.tsx # the route "/documents" [id]/ page.tsx # the route "/documents/123"PHP bridge: nesting folders to make URLs is like dropping
about.php in a directory — but here the folder name is the path and page.tsx is the handler.
A page.tsx default-exports a React component. A nested folder makes a nested route. A layout.tsx wraps every page beneath it and persists across navigations — perfect for a top bar.
app/documents/layout.tsx
export default function DocsLayout({ children }: { children: React.ReactNode }) { return ( <section> <h1>Your documents</h1> {children} {/* the page renders here */} </section> ); }
Navigate with <Link>, not a plain <a>. It does a fast client-side transition and prefetches the target:
import Link from "next/link"; <Link href="/documents">View documents</Link>
Dynamic routes capture part of the URL. A folder named [id] matches any value; the page receives it as a param:
app/documents/[id]/page.tsx
export default async function DocPage({ params }: { params: { id: string } }) { return <p>Showing document {params.id}</p>; }
loading.tsx in a folder and Next shows it automatically while that route's data is fetching — no spinner state to wire up. Drop an error.tsx and any thrown error renders it instead of a white screen.
This is the concept that trips people up — and the one interviewers probe. In the App Router, every component is a Server Component by default.
Runs on the server only. It can be async, can await fetch(...) your backend directly, and can read secrets — but it has no useState, useEffect, or onClick, because there's no browser involved.
// no "use client" — this runs on the server export default async function Page() { const res = await fetch("http://localhost:8000/health"); const data = await res.json(); return <p>{data.status}</p>; }
Add the string "use client" as the very first line of the file. Now it runs in the browser and can use state, effects, and event handlers — everything interactive.
app/components/Counter.tsx
"use client"; import { useState } from "react"; export default function Counter() { const [n, setN] = useState(0); return <button onClick={() => setN(n + 1)}>Clicked {n}</button>; }
'use client' and run in the browser for interactivity. The rule of thumb: fetch and render on the server, and push 'use client' down to only the small interactive leaves — a button, a form, a search box."
Because a server component can be async, fetching is just await fetch(...) — no useEffect, no loading flags. The result is ready before the HTML is sent.
const res = await fetch(`${process.env.API_URL}/documents`, { cache: "no-store", // always fresh; omit to cache }); const docs = await res.json();
Environment variables split into two worlds, and confusing them is a real security bug:
| Variable | Visible where |
|---|---|
API_URL, DB_PASSWORD | Server only. Safe for secrets and tokens. |
NEXT_PUBLIC_API_URL | Inlined into the browser bundle. Never secret. |
Rule: anything prefixed NEXT_PUBLIC_ ships to the browser. Your FastAPI base URL used in server components stays a plain API_URL; only put truly public values behind NEXT_PUBLIC_.
$_ENV['DB_PASS'] in .env — it never left the server. NEXT_PUBLIC_ is the opposite: deliberately printed into the page the browser downloads.
/documents route as a server component that fetches the document list from your FastAPI backend and renders it. No spinners, no useEffect — the list is in the HTML on first paint. This is the first real screen of your capstone's UI.
Assuming API_URL=http://localhost:8000 in .env.local and a backend that returns [{ "id": 1, "filename": "intro.pdf" }, ...]:
app/documents/page.tsx
import Link from "next/link"; type Doc = { id: number; filename: string }; export default async function DocumentsPage() { const res = await fetch(`${process.env.API_URL}/documents`, { cache: "no-store", }); if (!res.ok) throw new Error("Failed to load documents"); const docs: Doc[] = await res.json(); return ( <ul> {docs.map((doc) => ( <li key={doc.id}> <Link href={`/documents/${doc.id}`}> <span className="filename">{doc.filename}</span> </Link> </li> ))} </ul> ); }
Read what just happened: the component is async, it awaits your FastAPI endpoint on the server, throws on failure (so error.tsx can catch it), and each filename links to its detail route. Add a loading.tsx beside it and the spinner is free. That's a production-shaped data screen in ~25 lines.
Answer from memory — retrieval is what moves this from "I read it" to "I know it".
Which file turns a folder into a route?
What kind of component is the default?
What lets a component use state and clicks?
Which env var reaches the user's browser?
How does a server component get its data?