Fifteen chapters of Next.js, and what actually stuck
React hands you components and a way to render them, and then leaves the rest of the building to you: routing, fetching data, deciding what runs where, making any of it fast. Next.js is a set of answers to exactly those questions. It fills the gaps React leaves open so you can spend your time on the app instead of on the plumbing.
I worked through the official course and turned every chapter into a short write-up afterwards — partly so I could find things back later, partly because writing something down is the fastest way to discover you did not actually understand it. This is all fifteen of them, in order. For each one: what it does, why it exists, and how I use it.
1. Getting started
Every project starts with one command that puts a complete structure on disk: config files, an /app folder, and optional TypeScript support. No build system to wire up, no router to configure, no TypeScript config to write.
# new project
npx create-next-app@latest my-app
# run it
cd my-app
npm run dev
# → http://localhost:3000
The folder layout is worth learning early, because you see it in every Next.js project:
my-app/
├── app/
│ ├── lib/ ← functions: data fetching, helpers
│ ├── ui/ ← components: buttons, cards, tables
│ └── page.tsx ← the homepage (route: /)
├── public/ ← images and static files
└── next.config.ts
This is also where TypeScript shows up: a layer on top of JavaScript that enforces types. In practice it stops you passing a string where a number is expected, and it tells you in the editor rather than in production.
The idea: Next.js gives you a finished foundation, so you can start on features instead of on infrastructure.
2. CSS styling
A bare HTML page is not very inviting. There are several ways to style one, and the course walks through them rather than picking for you:
- Global CSS — one stylesheet imported once, usually in the root layout, that applies everywhere. Good for base styles: fonts, resets.
- Tailwind — instead of writing separate CSS files, you add small reusable classes straight onto the element. It feels strange for about a day, and then you notice you have stopped inventing class names.
- CSS Modules — for when you would rather write ordinary CSS per component, without class names colliding across the app. Next.js generates unique names for you.
Alongside those is clsx, a small library for applying class names conditionally — an invoice status that should be green when paid and grey when pending, written readably instead of as a stack of ternaries.
// Tailwind: styling straight in the className
export default function Card() {
return (
<div className="flex items-center gap-2 rounded-lg bg-white p-4 shadow">
<p className="text-sm text-gray-600">Example text</p>
</div>
);
}
// clsx: className depending on a status
import clsx from 'clsx';
function Status({ status }: { status: 'paid' | 'open' }) {
return (
<span
className={clsx('rounded-full px-2 py-1 text-sm', {
'bg-green-500 text-white': status === 'paid',
'bg-gray-100 text-gray-500': status === 'open',
})}
>
{status}
</span>
);
}
The idea: pick the approach that suits you, and let clsx handle the parts that depend on state.
3. Optimising fonts and images
Two things that traditionally go wrong on websites.
Fonts. When a custom typeface arrives after the page has already rendered, the text jumps size. That is Cumulative Layout Shift, and it is bad for the reader and for how Google rates the page. next/font fixes it by downloading the font at build time and serving it locally — no extra network request during the visit, and nothing to shift.
Images. With a plain <img> you are responsible for responsiveness, multiple sizes for multiple screens, layout shift and lazy loading. The <Image> component from next/image does all of it: it converts the format (to WebP, for instance), sizes the image to the visitor's screen, and reserves the space so the page does not jump while it loads.
// app/ui/fonts.ts — load a Google Font
import { Inter } from 'next/font/google';
export const inter = Inter({ subsets: ['latin'] });
// app/layout.tsx — apply it to the whole app
import { inter } from '@/app/ui/fonts';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}
import Image from 'next/image';
<Image
src="/hero.png"
width={1000}
height={760}
alt="Example image of the dashboard"
/>
The idea: Next.js takes the performance work you would normally have to research yourself and automates it — you just have to use the right component.
4. Layouts and pages: file-based routing
This is the one that reframes everything else. There is no router to configure and no route file to keep in sync: your folder structure is your URLs. A folder called dashboard with a page.tsx in it is reachable at /dashboard, and that is the whole mechanism.
Two filenames are special. page.tsx is what makes a route actually reachable and holds that page's content. layout.tsx is UI shared between pages — a sidebar that should appear on every dashboard screen.
app/
└── dashboard/
├── layout.tsx ← shared nav for every dashboard page
├── page.tsx ← route: /dashboard
└── customers/
└── page.tsx ← route: /dashboard/customers
// app/dashboard/layout.tsx
import SideNav from '@/app/ui/sidenav';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen flex-col md:flex-row">
<SideNav />
<div className="grow p-6">{children}</div>
</div>
);
}
The part I did not expect is partial rendering. Navigating between two pages inside the same layout only refreshes the page content — the layout itself is not re-rendered, so whatever state it was holding survives. An open menu stays open across a navigation without you writing anything to make that happen.
Every app also needs a root layout at /app/layout.tsx, which holds the outer <html> and <body> tags and therefore applies to everything.
The idea: folders are routes, page.tsx is the content, layout.tsx is shared UI that does not have to re-render on navigation.
5. Navigating between pages
You could navigate with a plain <a> tag, but that reloads the whole page — slow, and unnecessary. The <Link> component from next/link looks almost identical in use but navigates client-side: only the part that changed is updated. Next.js goes further and prefetches links as they come into view, so the next page is largely ready before the click.
Alongside it is usePathname(), which tells you which route the visitor is on. The common use is marking the active item in a nav by comparing the current path with the link.
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import clsx from 'clsx';
const links = [
{ name: 'Dashboard', href: '/dashboard' },
{ name: 'Customers', href: '/dashboard/customers' },
];
export default function NavLinks() {
const pathname = usePathname();
return (
<>
{links.map((link) => (
<Link
key={link.href}
href={link.href}
className={clsx('block p-2', { 'bg-blue-100': pathname === link.href })}
>
{link.name}
</Link>
))}
</>
);
}
The idea: use <Link> instead of <a> for fast client-side navigation, and usePathname() to know where the visitor is.
6. Setting up a database
Up to this chapter everything runs on placeholder data — loose JavaScript objects. Now it gets real: a PostgreSQL database. The course uses a Vercel marketplace integration, but the principle holds for any Postgres provider.
The steps are the ones any database project has: push the project to a Git repository (needed for deployment), create a database instance and store the connection string in environment variables — never hardcoded — and then seed it, using the placeholder data you already had to fill the database with initial rows.
# .env — connection details, never in your code
POSTGRES_URL="postgresql://user:password@host:5432/database"
// app/lib/seed.ts (simplified)
import { sql } from '@vercel/postgres';
import { customers } from './placeholder-data';
async function seedCustomers() {
for (const customer of customers) {
await sql`
INSERT INTO customers (name, email)
VALUES (${customer.name}, ${customer.email})
`;
}
}
The idea: this chapter is the bridge between "fake data in code" and a real source the rest of the app can lean on.
7. Fetching data
With a database in place, you start filling the dashboard. There are two routes to the same result.
An API layer is a middle layer you build yourself — useful if other clients, a mobile app for instance, need the same data, or if a third party like an ORM handles it for you.
Querying directly is possible when you work with Server Components, because they run on the server and therefore already have safe access to backend resources. No API layer purely as a courier, and no sensitive logic leaking into the browser.
The trap here is the network waterfall: requests that run one after another because you wrote them one after another, not because the second needs the first.
// ✗ Waterfall — the second request waits for no good reason
const customers = await getCustomers();
const invoices = await getInvoices();
// ✓ Parallel — both start at once
const [customers, invoices] = await Promise.all([
getCustomers(),
getInvoices(),
]);
// Server Component: fetch directly, no API route in between
export default async function DashboardPage() {
const invoices = await sql`SELECT * FROM invoices LIMIT 5`;
return <InvoiceList invoices={invoices.rows} />;
}
Two lines that look nearly identical, and the difference is the sum of both requests versus the slower of the two.
The idea: Server Components let you fetch safely and directly on the server, and fetching in parallel instead of in sequence keeps pages fast.
8. Static versus dynamic rendering
Once you are fetching data, a question follows: when does Next.js generate the HTML?
- Static rendering — built during the build (or cached periodically). Very fast for the visitor and good for SEO, but the data can go stale, because the page is not regenerated per visit.
- Dynamic rendering — rendered on the server on every request, with the freshest data. Necessary for anything that differs per user, like a personal dashboard, or that changes constantly.
// Force a route to be dynamic — always fresh, never cached
export const dynamic = 'force-dynamic';
export default async function DashboardPage() {
const data = await getLatestData();
return <Dashboard data={data} />;
}
The chapter also shows what happens when one of your requests is slow: without further work it blocks the entire page, because Next.js waits for all the data before showing anything.
The idea: static for speed on content that rarely changes, dynamic for current and user-specific content — and an open problem: what do you do about slow requests?
9. Streaming: don't wait for the slowest data
Streaming is the answer to the question chapter 8 ends on. Instead of waiting until all the data is in, Next.js sends the page in pieces as they become ready. Fast parts appear immediately, slow parts follow when their data arrives, and nobody stares at a blank white page in the meantime.
Two building blocks. loading.tsx is a special file that automatically shows a loading state for a whole route while the page is prepared in the background. React's <Suspense> gives finer control: you wrap one specific slow component, give it a fallback, and the rest of the page is visible immediately while just that part shows a skeleton.
// app/dashboard/loading.tsx — automatic loading state for the route
export default function Loading() {
return <p>Loading…</p>;
}
// Finer-grained: only this one slow part shows a skeleton
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div>
<FastWidget />
<Suspense fallback={<CardSkeleton />}>
<SlowRevenueCard />
</Suspense>
</div>
);
}
This chapter also introduces route groups: folders wrapped in parentheses that organise your routing without appearing in the URL — handy for grouping related loading.tsx files.
The idea: show what is ready and let the rest stream in. The total load time is identical; it just stops feeling like waiting, which turns out to be most of what people mean by fast.
10. Search and pagination
For a list of invoices you want search and paging. The clever move in this chapter is to store the search term and page number in the URL — ?query=lee&page=2 — rather than in local React state.
Why that is better: the page becomes shareable and bookmarkable with the exact search in it; with server-side rendering the server can fetch the right data straight from the URL, without extra client logic; and it is easier to test and to track in analytics, because the state is visible.
Three hooks do the work together. useSearchParams() reads the current parameters, usePathname() gives the current path so you know what to rebuild, and useRouter() navigates to the new URL as the user types.
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
export default function SearchBar() {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
function search(term: string) {
const params = new URLSearchParams(searchParams);
term ? params.set('query', term) : params.delete('query');
replace(`${pathname}?${params.toString()}`);
}
return <input onChange={(e) => search(e.target.value)} placeholder="Search…" />;
}
// The Server Component reads the same parameters to filter
export default async function InvoicesPage({
searchParams,
}: {
searchParams: Promise<{ query?: string }>;
}) {
const { query = '' } = await searchParams;
const invoices = await searchInvoices(query);
return <InvoiceList invoices={invoices} />;
}
The idea: use the URL as the source of truth for filters and paging — it makes the app shareable and lets the server do the heavy lifting instead of the client.
11. Mutating data with Server Actions
So far you have mostly been reading. This chapter covers changing things: creating, updating and deleting invoices. The technology is React Server Actions — functions marked 'use server' that run safely on the server but can be called straight from a form in your component, without you building an API endpoint.
In practice: you attach a Server Action to the action attribute of a <form>. The fields arrive as a FormData object, which you read and — preferably with type validation, via something like Zod — check before writing anything. After a change, Next.js still holds the old cached page, so revalidatePath() is how you say which route is now stale. For pages like "edit invoice X" you use dynamic route segments: a folder named [id], so one component serves every invoice with the ID as a parameter.
// app/lib/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createInvoice(formData: FormData) {
const customerId = formData.get('customerId');
const amount = Number(formData.get('amount'));
await sql`
INSERT INTO invoices (customer_id, amount, status)
VALUES (${customerId}, ${amount}, 'open')
`;
revalidatePath('/dashboard/invoices');
}
// app/dashboard/invoices/new/page.tsx
import { createInvoice } from '@/app/lib/actions';
export default function NewInvoicePage() {
return (
<form action={createInvoice}>
<input name="customerId" />
<input name="amount" type="number" />
<button type="submit">Create</button>
</form>
);
}
The idea: Server Actions connect forms directly to server logic without separate API routes, and revalidatePath makes sure people see the current data afterwards. Skip it and users keep seeing data they just changed, which is a confusing bug to chase.
12. Handling errors properly
Things go wrong eventually: a database is unreachable, or someone opens an invoice that does not exist. Next.js has two mechanisms.
error.tsx goes in a route folder, and Next.js catches unexpected errors inside that segment and shows this fallback UI instead — without the whole application crashing.
notFound() and not-found.tsx are for the different case where the resource simply is not there. Rather than an ugly error you call notFound(), and Next.js shows a proper "not found" page, the equivalent of a classic 404.
// app/dashboard/invoices/error.tsx
'use client';
export default function Error({ reset }: { reset: () => void }) {
return (
<div>
<h2>Something went wrong</h2>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
// Invoice not found → show the 404-style page
import { notFound } from 'next/navigation';
export default async function InvoicePage({ id }: { id: string }) {
const invoice = await getInvoice(id);
if (!invoice) notFound();
return <InvoiceDetails invoice={invoice} />;
}
The idea: catching errors at route level keeps the rest of the app working and always gives the visitor something they can understand instead of a broken page.
13. Improving accessibility
A good application works for everyone, including people using a screen reader or navigating without a mouse. Two sides to it.
Automated checking with eslint-plugin-jsx-a11y warns you while you type about the common mistakes — an image without alt text, a form field without a label.
Server-side form validation, because client-side validation can be bypassed and is therefore a convenience, not a guarantee. To show the resulting messages back to the user — and to the screen reader — you use React's useActionState hook, which tracks the state and errors of a Server Action.
'use client';
import { useActionState } from 'react';
import { createInvoice } from '@/app/lib/actions';
export default function InvoiceForm() {
const [state, formAction] = useActionState(createInvoice, { errors: {} });
return (
<form action={formAction}>
<input name="amount" aria-describedby="amount-error" />
<div id="amount-error" aria-live="polite">
{state.errors?.amount?.map((err: string) => <p key={err}>{err}</p>)}
</div>
<button type="submit">Save</button>
</form>
);
}
The idea: accessibility is both correct HTML and ARIA — supported by linting — and reliable server-side validation with messages that actually reach the person who needs them.
14. Adding authentication
A dashboard should not be open to everyone. This chapter adds login with NextAuth.js (Auth.js), which handles the heavy parts — sessions, tokens, providers.
Two terms that are easy to blur together: authentication confirms who someone is; authorisation then decides what they may see or do.
To protect routes you use a proxy function that runs before every request, checks whether the visitor is signed in, and sends them to the login page if not.
// auth.config.ts (simplified)
export const authConfig = {
pages: { signIn: '/login' },
callbacks: {
authorized({ auth, request }: any) {
const signedIn = !!auth?.user;
const onDashboard = request.nextUrl.pathname.startsWith('/dashboard');
if (onDashboard) return signedIn;
return true;
},
},
};
// middleware.ts — runs before every request
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
export default NextAuth(authConfig).auth;
export const config = {
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};
The idea: NextAuth handles the complicated side of logging in, while a proxy layer decides which routes are reachable without a valid session.
15. Adding metadata
The final chapter is about what search engines and social platforms see when they look at your page — which decides both whether you are findable and what your link looks like when it is shared.
Next.js has a built-in Metadata API. In the root layout you define a metadata object with a title and description that apply everywhere by default. To give a page its own title without repeating the company name every time, you use a title template: set it once in the root layout, and each page only supplies its own part.
The chapter also covers adding an Open Graph image — the preview that appears when your link is shared on WhatsApp or LinkedIn — and a favicon.
// app/layout.tsx — default title + template for every page
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: { template: '%s | Acme Dashboard', default: 'Acme Dashboard' },
description: 'The official Next.js dashboard, built with the App Router.',
};
// app/dashboard/invoices/page.tsx — page-specific title
export const metadata = { title: 'Invoices' };
// Browser tab: "Invoices | Acme Dashboard"
This one landed differently than it would have a month ago. I had just spent an afternoon writing titles, descriptions, canonical tags and structured data into this site by hand, one page at a time. Seeing the same job reduced to an exported object was a small, slightly annoying revelation.
The idea: metadata is usually a few lines of configuration, but it largely determines how professional and how findable your application looks.
The bigger picture
Laid out end to end, the fifteen chapters have a shape:
- Foundation (1–5) — set up the project, style it, route and navigate.
- Data (6–11) — connect a database, fetch, render deliberately, stream, filter, mutate.
- Finishing (12–15) — error handling, accessibility, authentication, metadata.
That is not only a teaching order. It is roughly the order a real project grows in, which is probably why the course does not feel like a tour of features. You go from an empty page to something complete, secured and findable, in the sequence you would actually have hit those problems.
The theme running underneath all of it is that the work moves back to the server — fetching, mutating, validating, rendering — and the browser is left with less to do. React spent years pushing everything to the client. This is part of the swing back, and after fifteen chapters it reads less like a trend and more like the plumbing finally being put where it belongs.