Modern frontend — React and Next.js
Components, state, routing, server components. Build a real app, not a counter. The framework stack that gets you hired in 2026.
Prerequisites
03.1
Stack
Node 22 LTSNext.js 15 plus, App RouterReact 19TypeScriptTailwind CSS or vanilla CSSshadcn/ui (when needed)
By the end of this module
- Build components that compose, with props, state, and effects you can actually justify.
- Decide between Server and Client components on purpose, not by accident.
- Fetch data on the server by default, mutate via Server Actions, and only reach for a client cache when you need one.
- Ship a real Next.js app with search, infinite scroll, and dark mode to Vercel on a custom subdomain.
The previous module made the case that most “frontend” projects do not need React. This one makes the case that the rest of them do. Once a UI has real state — a logged-in user, a search box, a list that mutates, optimistic updates — vanilla JS becomes a maintenance hazard. React’s mental model is the cheapest way to keep that complexity organized, and Next.js is the cheapest way to render React on real infrastructure.
The 2026 React you’re learning is not the 2018 React on most tutorials. Server Components are the default. Server Actions replace half the API endpoints you used to write. useEffect is a code smell more often than a tool. If your reflex is “I need useEffect to fetch data,” you are about to learn a better one.
The build for this module is a real Hacker News reader: search, infinite scroll, story detail page, dark mode, deployed to Vercel on a custom subdomain. Not a counter. Not a todo list. Something a person would actually keep open in a tab.
Set up
# Use the official starter so you get the exact recommended configuration
pnpm create next-app@latest hn-reader --ts --tailwind --app --eslint --src-dir
cd hn-reader
# Optional but recommended
pnpm add zod
pnpm dlx shadcn@latest init # only if you'll use shadcn/ui
Pick pnpm over npm for this. It’s faster and disk-cheap, and most modern Next/React tooling assumes it. If you prefer npm or bun, every command in this module has an obvious equivalent.
Run pnpm dev, open http://localhost:3000, and confirm Tailwind classes work before changing a single line.
Read these first
Four resources, in order, then stop:
- React docs — Quick start and Thinking in React. react.dev · 60 min · the canonical mental model, written by the team. Do every “Try out some JSX” exercise.
- Next.js — App Router learning path. nextjs.org/learn · 2–3 hrs · official, current, free. Skim for vocabulary, then return to specific sections as you build.
- Vercel — React Server Components in detail. post · 30 min · the single most important concept in the App Router.
- Dan Abramov — A complete guide to useEffect. post · 45 min · old, still right. Read it before you use
useEffecteven once.
Stop there. The “30 React hooks you must know” listicles are noise. You will need maybe four hooks for this build.
The mental model in one paragraph
A React component is a pure function that takes props and returns a description of UI. State is the part of that description that changes over time. Effects are escape hatches for syncing with things outside React (the URL, a websocket, a chart library). Anything you can compute from props or state, do not store in state. Anything you fetch from a server, prefer to fetch on the server. Anything you imperatively poke at after render is probably wrong.
If you internalize that paragraph, two-thirds of React anti-patterns become impossible to write.
Step 1 — Server vs Client components
In the App Router, every component is a Server Component by default. They run on the server, can be async, and can read directly from a database or API. They send zero JavaScript to the browser unless they need to.
// src/app/page.tsx — Server Component (default)
type Story = { id: number; title: string; url: string; score: number };
async function getTopStories(): Promise<Story[]> {
const ids: number[] = await fetch(
'https://hacker-news.firebaseio.com/v0/topstories.json',
{ next: { revalidate: 60 } }
).then((r) => r.json());
const top = ids.slice(0, 30);
return Promise.all(
top.map((id) =>
fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`).then((r) =>
r.json()
)
)
);
}
export default async function HomePage() {
const stories = await getTopStories();
return (
<main className="mx-auto max-w-3xl p-6">
<h1 className="text-3xl font-semibold">Top stories</h1>
<ol className="mt-6 space-y-4">
{stories.map((s, i) => (
<li key={s.id}>
<a href={s.url} className="hover:underline">
{i + 1}. {s.title}
</a>
<span className="ml-2 text-sm text-zinc-500">{s.score} points</span>
</li>
))}
</ol>
</main>
);
}
That page makes 31 network calls on the server and ships zero JS to the browser. View source in your dev tools — the markup is fully rendered. Lighthouse Performance will be embarrassingly easy to keep at 95 plus on this kind of page.
Drop down to a Client Component only when you actually need browser-side state, effects, or event handlers:
// src/components/search-box.tsx
'use client';
import { useState } from 'react';
export function SearchBox({ onSearch }: { onSearch: (q: string) => void }) {
const [q, setQ] = useState('');
return (
<input
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onSearch(q)}
placeholder="Search stories"
className="w-full rounded border p-2"
/>
);
}
The 'use client' directive is a boundary. Everything imported from a Client Component is also client-side. Keep that boundary as far down the tree as possible — pushed into the leaves where interactivity actually lives.
Step 2 — Routing and layouts
Files become routes. Folders become path segments. layout.tsx files wrap their subtree.
src/app/
layout.tsx // root layout (the <html><body>)
page.tsx // /
story/
[id]/
page.tsx // /story/123
search/
page.tsx // /search?q=...
Dynamic segments come in as params. Search params come in as searchParams. Both are async props in Next 15:
// src/app/story/[id]/page.tsx
type Props = { params: Promise<{ id: string }> };
export default async function StoryPage({ params }: Props) {
const { id } = await params;
const story = await fetch(
`https://hacker-news.firebaseio.com/v0/item/${id}.json`
).then((r) => r.json());
return <article>{story.title}</article>;
}
URL is state. Filters, sort, pagination, search query — put them in the URL with searchParams, not in component state. The links are shareable, the back button works, and refresh restores everything.
Step 3 — Forms and Server Actions
Server Actions let you call server code directly from a form, no API route:
// src/app/comment/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
const Schema = z.object({
storyId: z.coerce.number(),
body: z.string().min(1).max(500),
});
export async function postComment(formData: FormData) {
const parsed = Schema.safeParse({
storyId: formData.get('storyId'),
body: formData.get('body'),
});
if (!parsed.success) return { error: 'Invalid input' };
// pretend this hits your DB
// await db.comment.create({ data: parsed.data });
revalidatePath(`/story/${parsed.data.storyId}`);
return { ok: true };
}
// in any component
import { postComment } from './actions';
export function CommentForm({ storyId }: { storyId: number }) {
return (
<form action={postComment}>
<input type="hidden" name="storyId" value={storyId} />
<textarea name="body" required maxLength={500} />
<button type="submit">Post</button>
</form>
);
}
Validate every input with Zod (or Valibot). Trusting formData.get('body') raw is the same mistake as trusting query strings raw.
Step 4 — Styling — Tailwind vs CSS Modules
The honest opinion: use Tailwind. Not because it is intellectually beautiful, but because it removes one of the most expensive decisions in frontend (naming) and aligns the team on spacing, color, and type tokens by default. The “Tailwind makes HTML ugly” argument loses to “now I never debug a misnamed CSS class again.”
When Tailwind is genuinely the wrong tool: animation-heavy or art-directed pages. There, CSS Modules with custom properties beats utility classes. Reach for shadcn/ui when you need accessible primitives (modal, dropdown, command palette) — it generates the source code into your repo so you own it, instead of versioning a dependency you can’t change.
Avoid CSS-in-JS runtime libraries (styled-components, Emotion) in the App Router — they fight Server Components and ship JS you don’t need.
Step 5 — State, when you actually need it
Three buckets, three different tools:
| State kind | Lives where | Tool |
|---|---|---|
| Server data | The database / API | server fetch + revalidatePath, or TanStack Query for client-heavy apps |
| URL state | The URL | useSearchParams, <Link>, push to router |
| Local UI state | One component | useState |
| Cross-component client state | Many components | Zustand, only when prop-drilling actually hurts |
You do not need Redux. You do not need Recoil. You almost certainly do not need Jotai. The “you don’t need a state library yet” rule of thumb: if you can’t name three components that read the same state, you don’t have a state library problem yet.
Forms are their own bucket. React Hook Form is the right tool for anything beyond a single-field input. It avoids re-rendering on every keystroke, integrates with Zod, and handles the boring parts (focus management, errors, dirty state).
Step 6 — The build: HN reader
Build it in this order. Do not skip ahead.
- Top stories list. Server Component, 60-second revalidation, server-rendered.
- Story detail page. Dynamic route, fetches comments, renders top-level comments only at first.
- Search. A
/searchpage that takes?q=..., uses the Algolia HN search API. URL-driven. - Infinite scroll on the front page. A small Client Component using
IntersectionObserverthat calls a server action to fetch more. - Dark mode. Use
next-themes. Add a toggle. Test that the initial render isn’t a flash of the wrong theme —suppressHydrationWarningon<html>plus a tiny script in the layout. - Polish. Loading skeletons via
loading.tsx, error UI viaerror.tsx, 404 vianot-found.tsx. These are file-name conventions in App Router.
Deploy:
pnpm dlx vercel link
pnpm dlx vercel deploy --prod
Vercel auto-detects Next, builds it, gives you HTTPS, gives you previews on every branch. Total time from git push to live URL: under two minutes the first time, under 30 seconds after that.
Going deeper
- Next.js docs — App Router · the canon. Use it as a reference, not a tutorial.
- React docs — You might not need an effect · re-read every six months.
- TanStack Query docs · when client-side server-state caching is genuinely the right tool.
- Robin Wieruch’s React tutorials · long-form, dense, current.
- shadcn/ui · the right way to get accessible components without locking yourself into a library version.
Skip “Next.js vs Remix vs Astro in 2026” comparison videos. The frameworks are converging; pick one and ship.
Checkpoints
- Without looking at the docs, explain the difference between a Server Component and a Client Component, and give one concrete reason you’d push the
'use client'boundary down toward the leaves. - Why is putting a search query in
useStateinstead of in the URL almost always wrong? - Walk through what happens, end to end, when a user submits a form bound to a Server Action.
- Name three specific cases where
useEffectis the wrong tool, and what to use instead in each case. - Sketch the file/folder structure for the routes
/,/story/[id],/search?q=..., including anylayout.tsx,loading.tsx, anderror.tsxyou’d add.
When the HN reader is live on a Vercel URL with all six features working, move to 03.3 Modern backend — APIs, auth, databases, where you’ll learn how the data your frontend consumes actually gets stored and authenticated.