The previous module ended by pointing at a boundary: CicloUrbano is an app that downloads to the browser and runs there. That decision, which looked purely technical back in module 1, has consequences that memo can't fix, nor code splitting, nor tests. The first paint waits on JavaScript; search engines and social networks see an empty page; and on a slow connection the user stares at a blank screen for seconds. This lesson crosses that boundary. You're going to understand exactly what the browser does when it receives a single-page app, what the four rendering strategies are and when each one makes sense, what hydration is and why it's the point where SSR breaks, and how all of that gets built with Next.js 15 and its App Router. By the end you'll have ciclourbano-web up and running, CicloUrbano's public storefront rendered on the server, living alongside the management app you've spent nine modules building.
Contents
- What the browser gets today: an empty
<div> - What it means for first paint and slow connections
- What search engines and preview cards see
- The four rendering strategies
- What hydration is
- Server/client mismatches
- Why you need a framework
- Next.js 15: creating
ciclourbano-web - Folder-based routes:
layout,page,loading,error,not-found - From React Router's map to Next.js's map
- Server components: data with
async/await - The
'use client'directive, in four lines - Dynamic rendering: when Next.js decides to render on every request
- Metadata and SEO
- Session without
localStorage - Streaming: HTML in pieces
- What the browser gets today: an empty
<div>
<div>Open the Vite project's index.html for CicloUrbano and look at what actually gets sent to the browser. It isn't what you see on screen: it's this.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CicloUrbano</title>
<script type="module" crossorigin src="/assets/index-a3f9c2.js"></script>
<link rel="stylesheet" href="/assets/index-7b21e4.css" />
</head>
<body>
<div id="root"></div>
</body>
</html>That's all of CicloUrbano's HTML. An empty container and a <script> tag. Everything else — the header, the catalogue, the five bike cards, the footer — gets built by JavaScript in the browser afterward.
You can check this yourself without any special tools. In the browser, "View Page Source" (Ctrl+U) shows the HTML exactly as it arrived from the server, before anything runs. The element inspector, by contrast, shows the DOM once it's already built. The difference between those two views is exactly the difference between what the server sends and what React manufactures.
The real sequence, step by step, when someone opens the detail page for bici-002:
sequenceDiagram
participant N as Browser
participant S as Static server
participant A as API (json-server)
N->>S: GET /bicicletas/bici-002
S-->>N: HTML with empty div#root
Note over N: Blank screen
N->>S: GET /assets/index.js (270 KB gzip)
S-->>N: JavaScript
Note over N: Parse + execute
Note over N: React mounts · first render
N->>A: GET /bicicletas/bici-002
A-->>N: JSON
Note over N: Second render · content visible
There are three chained round trips before the bike's model shows up: the HTML, the JavaScript, and the data. And they're sequential: the browser can't request the data until it has run the JavaScript, and it can't run the JavaScript until it has received the HTML that references it.
- What it means for first paint and slow connections
Translated into the metrics used to measure the real experience:
| Metric | What it measures | In a pure SPA |
|---|---|---|
| TTFB (Time To First Byte) | How long the first byte of HTML takes | Very good: the HTML is tiny and static |
| FCP (First Contentful Paint) | When the first content appears | Bad: it waits on downloading and running the JS |
| LCP (Largest Contentful Paint) | When the main element appears | Very bad: it also waits on the API data |
| TTI (Time To Interactive) | When it responds to a click | Roughly coincides with the LCP |
TTFB is deceptive: it's excellent, but what arrives in that first byte is useless. What the user perceives is the FCP and the LCP, and both sit at the end of a chain of waits.
Now add the variable that gets forgotten when you develop on a laptop with fiber: not everyone has your connection or your phone. Module 8 already showed that parsing and running JavaScript on a mid-range phone runs 3 to 5 times slower. On a congested 4G network, the compressed 270 KB alone take more than a second just to arrive.
And there's a case worse than slow: the one that fails. If the JavaScript file doesn't download — an unstable network, an overly aggressive blocker, a CDN deployment error —, the user doesn't see a degraded version of CicloUrbano. They see a blank page. With HTML rendered on the server, that same user sees the content; what they lose is interactivity, which is a far more reasonable degradation.
Key idea. An SPA makes content depend on code. Server rendering breaks that dependency: content arrives as content, and code only adds interactivity on top.
- What search engines and preview cards see
This is the argument that usually settles the migration, because it's money.
Picture the public detail page for bici-002, the Electric Pro at Main Square. In the SPA, the HTML that arrives is the empty <div id="root"> and a generic <title>CicloUrbano</title>. Everything else — the model name, the price of €4.00/h, the district — appears later, in the DOM.
What happens with each consumer of that HTML:
| Consumer | Does it run JavaScript? | What it sees of bici-002 |
|---|---|---|
| Googlebot | Yes, but in a deferred second pass | May eventually index it, with a delay and no guarantees |
| Other search engines | Often not, or only partially | An empty page |
| WhatsApp, Slack, Telegram | No | Generic title, no description or image |
| Twitter/X, LinkedIn, Facebook | No | The same |
| RSS readers, aggregators, SEO tools | Almost never | Nothing |
The case of preview cards is the clearest and easiest to verify. When someone pastes https://ciclourbano.test/bicicletas/bici-002 into a chat, the messaging app makes a plain HTTP request and looks in the HTML for the <meta property="og:title">, og:description, and og:image tags. It never runs JavaScript, not now, not ever. If those tags get set from React with useEffect, the messaging app will never see them.
With the SPA, the preview for any bike in the catalogue is identical:
With SSR, each one is its own:
Electric Pro · Main Square — CicloUrbano Electric bike available at the Main Square station (Downtown). €4.00/h. Book by the hour from the website. [image of the bike]
The second gets shared; the first doesn't. And that's a business problem, not a performance one.
- The four rendering strategies
This table is the backbone of the module. Everything that comes in 10-01 and 10-02 is a footnote to these four rows.
| CSR | SSR | SSG | ISR | |
|---|---|---|---|---|
| Name | Client-side rendering | Server-side rendering | Static generation | Incremental regeneration |
| When the HTML is generated | In the browser, after the JS loads | On every request | Once, at next build |
At build time, and then every N seconds, on the first request after it expires |
| Who generates it | The user's browser | The Node server | The build machine | The server, in the background |
| What's seen first | An empty div |
The full HTML | The full HTML | The full HTML |
| When it goes stale | Never (always fresh) | Never | As soon as the data changes | At most, N seconds |
| Cost per visit | None on the server | One server render | Serving a file | Serving a file (plus an occasional render) |
| Personalized data | Yes | Yes | No | No |
| Ideal content | Panels behind sign-in | Catalogue with live availability, search results | Informational pages, terms of service | Bike detail pages, station listings |
Applied to CicloUrbano, the quick read is:
- The workshop panel (
/taller) is private, personalized by role, and nobody indexes it: CSR is fine. - The public catalogue with live availability changes every few minutes and needs to be indexable: SSR.
- The terms of service page changes twice a year: SSG.
- A bike's detail page changes rarely but does change, and there are hundreds of them: ISR.
In this lesson we build the SSR column. The two on the right are the next lesson.
One important clarification, to avoid confusion from the start: these four strategies aren't alternative frameworks where you pick one for the whole project. In Next.js with the App Router, they're chosen route by route, and you can even mix a static part with a dynamic island within a single route. That's precisely why a framework earns its keep.
- What hydration is
With SSR, the server generates the catalogue's HTML and sends it. The browser paints it right away: the user sees the five bike cards at 400 ms. But that HTML is inert. The buttons don't respond, TypeSelector doesn't filter, there's no state, there are no handlers.
Hydration is the process by which React, now in the browser, walks that existing HTML and "brings it to life": it mounts the component tree, creates the state, and attaches event handlers to the DOM nodes that are already there, without recreating them.
flowchart TB
subgraph SERVER
A["React renders the tree"] --> B["HTML as text"]
end
B --> C["The browser paints the HTML<br/>Content VISIBLE but inert"]
C --> D["The JavaScript bundle arrives"]
D --> E["hydrateRoot: React walks the existing DOM"]
E --> F["Associates components with nodes<br/>and attaches handlers"]
F --> G["INTERACTIVE app"]
It's useful to contrast it with what you already know:
createRoot(...).render(...) |
hydrateRoot(...) |
|
|---|---|---|
| Starting point | An empty container | HTML already generated by the server |
| What it does with the DOM | Creates it whole | Reuses it and just attaches to it |
| If it doesn't match what's expected | Doesn't apply | Warns and, in the worst case, discards it and recreates it |
The key word is reuses. React starts from the premise that the server's HTML is exactly what it would itself produce on the client's first render. If that premise breaks, the problem from the next section shows up.
And a consequence worth internalizing right now: SSR doesn't eliminate JavaScript. The bundle still gets downloaded and run. What SSR does is decouple the moment content becomes visible from the moment the app becomes interactive. Between the FCP and the TTI there's now a window where the page is visible but doesn't respond: this is what's called hydration's "uncanny valley." Shrinking it is exactly what 10-03's React Server Components are after.
- Server/client mismatches
A hydration error (hydration mismatch) happens when the HTML React generates on the client during the first render doesn't match the one that arrived from the server. In React 19 the message is explicit and shows a diff:
Hydration failed because the server rendered HTML didn't match the client.
As a result this tree will be regenerated on the client.
<BikeCard>
<span className="date">
- Booked until 18:42 ← server
+ Booked until 20:42 ← clientThe causes almost always belong to the same family of problems: something that has a different value on the server and in the browser.
| Cause | Why it fails | Fix |
|---|---|---|
new Date(), toLocaleTimeString() |
The server is in UTC and the browser is in the user's time zone | Render the date in ISO and format it in an effect, or fix the time zone explicitly |
Math.random(), crypto.randomUUID() |
Two runs, two values | Generate the value once on the server and pass it as a prop; for accessibility identifiers, use useId |
localStorage, sessionStorage |
They don't exist on the server | Read them in useEffect, never during render |
window, document, navigator |
They don't exist on the server | Check typeof window !== 'undefined' or read them in useEffect |
Invalid HTML (<div> inside <p>) |
The browser fixes the markup and it no longer matches | Fix the markup |
| Browser extensions | They inject attributes before hydration | Not your fault; use suppressHydrationWarning on the affected node |
The most frequent case in CicloUrbano is dark mode. ThemeButton reads the saved preference from localStorage, and that code runs during render:
// WRONG in SSR: localStorage doesn't exist on the server
function ThemeProvider({ children }) {
const [theme, setTheme] = useState(localStorage.getItem('theme') ?? 'light');
// ...
}On the server this doesn't even get to disagree: it crashes, because localStorage isn't defined. The correct pattern separates the safe initial value from the real one:
// src/contexts/ThemeProvider.jsx
'use client';
import { createContext, useState, useEffect } from 'react';
export const ThemeContext = createContext(null);
function ThemeProvider({ children }) {
// 1. DETERMINISTIC initial value: the same on the server and the client.
const [theme, setTheme] = useState('light');
// 2. After hydration, and only then, the real preference gets read.
useEffect(() => {
const saved = window.localStorage.getItem('theme');
if (saved) setTheme(saved);
}, []);
useEffect(() => {
document.documentElement.dataset.theme = theme;
window.localStorage.setItem('theme', theme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export default ThemeProvider;What each piece does:
useState('light')gives a deterministic value: the server and the client's first render match, so hydration is clean.- The first
useEffectruns after hydration, already in the browser, wherewindowexists. If the saved preference was'dark', it triggers a second render and the theme changes. - The price of this fix is a flash: for a few milliseconds you see the light theme. The usual solution — outside the scope of this lesson — is a small inline script in the
<head>that setsdata-themebefore anything gets painted.
Rule of thumb. During render, a component can only use information the server also has. Everything else — storage, window size, time zone, randomness — belongs in
useEffect.
- Why you need a framework
React ships the basic piece: renderToString in react-dom/server turns a component tree into an HTML string. With that and an Express server, you can put together a minimal SSR setup in twenty lines.
And it works… until you want any of these things:
- Routing in two places at once, on the server and on the client, with the same routes and without duplicating the configuration.
- Preloading the route's data before rendering, and then serializing it into the HTML so the client doesn't request it again.
- Bundling two separate builds, the server's and the client's, with shared code and CSS that arrives in the first response.
- Splitting the code by route and having the server know which chunks to preload for each page.
- Caching the HTML per route, revalidating it, and deciding per route whether it's static or dynamic.
- Streaming, server components, image optimization, headers,
sitemap.xml…
Each point is a project on its own, and the combination of all of them is a framework. That's why the ecosystem doesn't write SSR by hand: it uses Next.js, Remix / React Router in framework mode, Astro, or TanStack Start. In this course we use Next.js, which is the most widespread and the one that develops the server component model you'll see in 10-03.
- Next.js 15: creating
ciclourbano-web
ciclourbano-webIt's worth being explicit here about what we're doing, because it's easy to get lost:
We are not going to rewrite CicloUrbano. The management app built with Vite — the internal catalogue, bookings, workshop, Redux, TanStack Query, module 9's tests — keeps existing exactly as it is. What we're creating now is a second project,
ciclourbano-web, the public storefront: the screens that get shared, get indexed, and are seen by people who haven't signed in.
The criteria for deciding what goes to the storefront is simple, and it comes down to three questions:
| Question | If the answer is "yes" | If it's "no" |
|---|---|---|
| Does someone see it without signing in? | Candidate for the storefront | Stays in the SPA |
| Do you want Google to index it, or for it to be shared in a chat? | Candidate for the storefront | Stays in the SPA |
| Is it a work tool with heavy interaction and state? | Stays in the SPA | Candidate for the storefront |
With that filter, the public catalogue, the bike detail page, the station pages, and the informational content get migrated. The workshop panel, booking management, and everything behind ProtectedRoute stay in Vite.
Creating the project:
The wizard asks questions. These are the answers for this module:
✔ Would you like to use TypeScript? … No ✔ Would you like to use ESLint? … Yes ✔ Would you like to use Tailwind CSS? … No ✔ Would you like your code inside a `src/` directory? … Yes ✔ Would you like to use App Router? (recommended) … Yes ✔ Would you like to use Turbopack? … Yes ✔ Would you like to customize the import alias (`@/*` by default)? … No
We say "no" to TypeScript because we'll cover it in 10-04 on the Vite project, and "no" to Tailwind because CicloUrbano already uses CSS Modules, which Next.js supports out of the box. The App Router is mandatory for everything that follows.
The server starts at http://localhost:3000. Since the fake API is still json-server at http://localhost:3001, the two processes coexist without stepping on each other.
The relevant structure after creation:
ciclourbano-web/ ├── src/ │ └── app/ │ ├── layout.js ← root layout: <html> and <body> │ ├── page.js ← the "/" route │ ├── globals.css │ └── favicon.ico ├── public/ ← files served as-is ├── next.config.mjs ├── jsconfig.json └── package.json
The first surprise is what's not there: no main.jsx, no createRoot, no index.html, no routes.jsx. Next.js takes care of startup and routing.
We carry CicloUrbano's variables over to src/app/globals.css without changing a single value:
/* src/app/globals.css */
:root {
--color-brand: #12805c;
--color-rented: #b45309;
--color-maintenance: #9b1c1c;
--color-bg: #f5f7fa;
--color-surface: #ffffff;
--color-text: #1f2933;
--color-border: #d9e2ec;
}
:root[data-theme='dark'] {
--color-bg: #10151b;
--color-surface: #1a222c;
--color-text: #e6edf3;
--color-border: #2b3542;
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: system-ui, sans-serif;
margin: 0;
}
- Folder-based routes:
layout, page, loading, error, not-found
layout, page, loading, error, not-foundIn React Router, a route is an object in routes.jsx. In Next.js's App Router, a route is a folder, and inside it certain file names carry a reserved meaning.
| File | What it is | Equivalent you already know |
|---|---|---|
page.jsx |
The route's content. Without it, the folder isn't navigable | A route's element |
layout.jsx |
Wrapper that persists across child navigations | A parent route with <Outlet /> |
loading.jsx |
Shown while the page loads | A <Suspense>'s fallback |
error.jsx |
Catches errors from the subtree | ErrorBoundary from 04-05 |
not-found.jsx |
404 response for the subtree | NotFoundPage |
template.jsx |
Like layout, but remounts on every navigation |
— |
route.js |
HTTP handler (not UI) | An API endpoint |
Folders that don't contain any of those files don't create routes: they're just for organizing. And there are two very useful naming conventions:
[bicicletaId]→ a dynamic segment, equivalent to:bicicletaId.(storefront)→ a route group: groups routes to share alayoutwithout adding anything to the URL.
The root layout is mandatory, and it's the only one that contains <html> and <body>:
// src/app/layout.jsx
import './globals.css';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
export const metadata = {
title: {
default: 'CicloUrbano · Urban Bike Rental',
template: '%s · CicloUrbano',
},
description: 'Rent urban, electric, and cargo bikes by the hour in your city.',
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<Header />
<main>{children}</main>
<Footer />
</body>
</html>
);
}Three things worth pointing out:
- The prop is called
children, not<Outlet />. Next.js injects the page or the child layout there. It's pure composition, just like module 4's. metadatais an exported object, not a component. Thetemplate: '%s · CicloUrbano'makes any page that exportstitle: 'Stations'end up asStations · CicloUrbano.- This component is a server component: it runs on the server and never gets sent to the browser. We'll cover that in section 11.
- From React Router's map to Next.js's map
This is the literal translation of CicloUrbano's routes.jsx into the storefront's folder tree. Remember we're only migrating the public part.
| Route (React Router) | File in Next.js | Notes |
|---|---|---|
/ (catalogue, ?tipo=) |
app/page.jsx |
searchParams replaces useSearchParams |
/bicicletas/:bicicletaId |
app/bicicletas/[bicicletaId]/page.jsx |
params replaces useParams |
/estaciones |
app/estaciones/page.jsx |
|
/estaciones/:estacionId |
app/estaciones/[estacionId]/layout.jsx |
The layout acts as a parent route with tabs |
↳ flota tab (index) |
app/estaciones/[estacionId]/page.jsx |
The index route is that level's own page.jsx |
↳ incidencias tab |
app/estaciones/[estacionId]/incidencias/page.jsx |
|
* (not found) |
app/not-found.jsx |
Returns a real 404, not a 200 |
errorElement |
app/error.jsx |
Must be a client component |
/acceso, /reservas, /taller |
— | Stay in the Vite SPA |
The resulting tree:
src/app/
├── layout.jsx → Layout + Header + Footer
├── page.jsx → CataloguePage
├── loading.jsx → PageSkeleton
├── error.jsx → ErrorBoundary
├── not-found.jsx → NotFoundPage
├── bicicletas/
│ └── [bicicletaId]/
│ ├── page.jsx → BikeDetailPage
│ └── not-found.jsx → "That bike doesn't exist"
└── estaciones/
├── page.jsx → StationsPage
└── [estacionId]/
├── layout.jsx → station header + tabs
├── page.jsx → FleetTab (index)
└── incidencias/
└── page.jsx → IncidentsTabCompare it with module 6's nested routes diagram: it's the same tree. What changes is that in React Router you declared it, and here you draw it with folders. Navigation also has a direct equivalence:
| React Router | Next.js |
|---|---|
<Link to="/estaciones"> |
<Link href="/estaciones"> (from next/link) |
useNavigate() |
useRouter() from next/navigation (client only) |
useParams() |
The params prop of page.jsx (or useParams on the client) |
useSearchParams() |
The searchParams prop of page.jsx |
<Outlet /> |
The children prop of layout.jsx |
loader |
The async component itself |
And here's where the first real nesting shows up, the station's layout with its tabs:
// src/app/estaciones/[estacionId]/layout.jsx
import Link from 'next/link';
import { notFound } from 'next/navigation';
import StationTabs from '@/components/StationTabs';
export default async function StationLayout({ children, params }) {
// In Next.js 15, params is a promise: it has to be awaited.
const { estacionId } = await params;
const response = await fetch(`http://localhost:3001/estaciones/${estacionId}`);
if (!response.ok) notFound();
const station = await response.json();
return (
<section>
<h1>{station.name}</h1>
<p>{station.district} · {station.docks} docks</p>
<StationTabs estacionId={estacionId} />
{children}
</section>
);
}Important points:
paramsis a promise in Next.js 15. It's awaited withawait. The same goes forsearchParams,cookies(), andheaders(). It's a change from earlier versions and a common source of confusion when reading older tutorials.notFound()interrupts the render and makes Next.js serve the nearestnot-found.jsxwith a real HTTP 404 status. In the SPA,NotFoundPagegot painted with a 200; search engines couldn't tell a deleted detail page from a valid one.- The station's header renders in the
layout, so it doesn't get requested again when switching tabs: exactly the behaviour we were after with module 6's nested routes.
- Server components: data with
async/await
async/awaitThis is the biggest conceptual shift in the lesson. In the App Router, every component is a server component by default, and a server component can be async.
Compare. This is how CicloUrbano fetched the catalogue with TanStack Query:
// SPA with Vite — src/pages/CataloguePage.jsx
import { useQuery } from '@tanstack/react-query';
import BikeList from '../components/BikeList';
import LoadingIndicator from '../components/LoadingIndicator';
function CataloguePage() {
const { data: bikes, isPending, isError } = useQuery({
queryKey: ['bikes'],
queryFn: () => fetch('http://localhost:3001/bicicletas').then((r) => r.json()),
});
if (isPending) return <LoadingIndicator />;
if (isError) return <p>The catalogue could not be loaded.</p>;
return <BikeList bikes={bikes} />;
}And this is how it's done in Next.js:
// src/app/page.jsx — SERVER component
import BikeList from '@/components/BikeList';
export default async function CataloguePage() {
const response = await fetch('http://localhost:3001/bicicletas', {
cache: 'no-store', // live availability: never cache
});
if (!response.ok) {
throw new Error('The catalogue could not be loaded.');
}
const bikes = await response.json();
return <BikeList bikes={bikes} />;
}What has disappeared, and why:
| Disappears | Why |
|---|---|
useQuery and the whole client cache |
The data resolves before rendering; there's no state to synchronize |
isPending and <LoadingIndicator /> |
Loading is handled by loading.jsx at the route level |
isError and the error message |
A throw bubbles up to the nearest error.jsx |
The request's useEffect |
There's no lifecycle: the function runs once, on the server |
| The request→render→request waterfall | The server already has the data when it renders |
There's a detail that deserves special attention: fetch('http://localhost:3001/...') runs on the Node server, not in the browser. That has three immediate consequences:
- There's no CORS. It's a server-to-server request.
- The API's URL can be private. The browser never sees it. In production,
http://api.internal:3001would work just fine. - You can use secrets. An API key in
process.env.API_KEYnever reaches the client. This is a huge security advantage, and also a risk if you get the side of the boundary wrong (we'll see that in 10-03).
And what goes up to the browser from this component is nothing. CataloguePage isn't part of the client's JavaScript bundle: only its result travels. That's the crux of the matter 10-03 develops.
A note about multiple requests. If the detail page needs the bike and its station, don't chain them if they don't depend on each other:
// Sequential: 120 ms + 120 ms = 240 ms — BAD if they're independent
const bike = await getBike(bicicletaId);
const stations = await getStations();
// In parallel: max(120, 120) = 120 ms — GOOD
const [bike, stations] = await Promise.all([
getBike(bicicletaId),
getStations(),
]);When there's real dependency — you need bike.stationId to request the station — the waterfall is unavoidable, and that's where section 16's streaming comes in.
- The
'use client' directive, in four lines
'use client' directive, in four linesA server component has no state, no effects, and no event handlers: it runs once, on the server, and never comes back. To get all that back, you put 'use client' on the first line of the file. That marks the module, and everything it imports, as client code: it gets bundled, sent to the browser, and hydrated, and there useState, useEffect, onClick, and browser APIs work again. In CicloUrbano, TypeSelector, ThemeButton, UserMenu, and the theme provider will carry it.
The boundary between server and client, its exact rules, and what can and can't cross it are explained in full in 10-03. For now, the operating rule is enough: if the component needs state, effects, or events, put 'use client' at the top; if it just paints data, leave it out.
// src/components/TypeSelector.jsx
'use client';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
const TYPES = ['todos', 'urbana', 'electrica', 'carga'];
export default function TypeSelector() {
const router = useRouter();
const currentPath = usePathname();
const searchParams = useSearchParams();
const currentType = searchParams.get('tipo') ?? 'todos';
function handleChange(event) {
const type = event.target.value;
const next = new URLSearchParams(searchParams);
if (type === 'todos') next.delete('tipo');
else next.set('tipo', type);
router.push(`${currentPath}?${next}`);
}
return (
<label>
Bike type
<select value={currentType} onChange={handleChange}>
{TYPES.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
</label>
);
}It's almost identical to module 6's TypeSelector: the only thing that changes is the next/navigation hooks instead of react-router's. The logic of keeping the filter in the URL, which we argued so hard for back then, carries over intact.
- Dynamic rendering: when Next.js decides to render on every request
Next.js does not render on the server on every request by default. Its default behaviour is to try to prerender the route at build time (that's SSG, and it's the topic of 10-02). It only switches to rendering on every request — dynamic rendering, which is SSR proper — when it detects that the route can't be known ahead of time.
These are the triggers:
| Trigger | Why it forces rendering on every request |
|---|---|
fetch(..., { cache: 'no-store' }) |
The data must be fresh on every visit |
await cookies() |
It depends on who's requesting the page |
await headers() |
It depends on the specific request |
The searchParams prop in a page |
It depends on the full URL, which isn't known at build time |
export const dynamic = 'force-dynamic' |
Explicit declaration |
connection() from next/server |
Explicit declaration that it waits for the request |
Important in Next.js 15: fetch is no longer cached by default. If you don't say anything, it behaves like no-store and the route becomes dynamic. For it to be static, you have to ask for it (cache: 'force-cache' or next: { revalidate: N }). In earlier versions it was the other way around, so a lot of older material says the opposite.
In CicloUrbano, the public catalogue is the textbook case for dynamic rendering: it shows real-time availability, so serving HTML from half an hour ago would be lying to the user.
// src/app/page.jsx
import BikeList from '@/components/BikeList';
import TypeSelector from '@/components/TypeSelector';
import FleetSummary from '@/components/FleetSummary';
export const metadata = {
title: 'Catalogue',
description: 'Check which bikes are available right now at each station.',
};
export default async function CataloguePage({ searchParams }) {
// searchParams is a promise in Next.js 15.
const { tipo } = await searchParams;
const response = await fetch('http://localhost:3001/bicicletas', {
cache: 'no-store',
});
const bikes = await response.json();
const visibleBikes = tipo && tipo !== 'todos'
? bikes.filter((bike) => bike.type === tipo)
: bikes;
const available = bikes.filter((b) => b.status === 'disponible').length;
return (
<>
<FleetSummary total={bikes.length} available={available} />
<TypeSelector />
<BikeList bikes={visibleBikes} />
</>
);
}Notice three decisions:
- The filtering happens on the server. What reaches the browser is only the HTML for the visible bikes. In the SPA, all of them got downloaded and filtered on the client with
useFilteredCatalogue. TypeSelectoris a client component, butBikeListandFleetSummaryare server components: they have no state, they just paint. They don't get downloaded.- When the filter changes,
router.pushtriggers a new request to the server that returns the updated HTML. Navigation is still client-side — there's no full reload — but the server generates the content.
You can verify it works: Ctrl+U on http://localhost:3000/?tipo=electrica and search for "Electric Pro" in the page source. It's there, in the HTML, before any JavaScript.
- Metadata and SEO
The whole promise from section 3 gets fulfilled here. Next.js offers two paths: the static metadata export, which you've already seen, and the generateMetadata function for cases where the title depends on the data.
// src/app/bicicletas/[bicicletaId]/page.jsx
import { notFound } from 'next/navigation';
import StatusBadge from '@/components/StatusBadge';
import BookingPanel from '@/components/BookingPanel';
async function getBike(bicicletaId) {
const response = await fetch(`http://localhost:3001/bicicletas/${bicicletaId}`, {
cache: 'no-store',
});
if (!response.ok) return null;
return response.json();
}
export async function generateMetadata({ params }) {
const { bicicletaId } = await params;
const bike = await getBike(bicicletaId);
if (!bike) {
return { title: 'Bike not found' };
}
const title = `${bike.model} · €${bike.pricePerHour.toFixed(2)}/h`;
const description =
`${bike.type} bike, ${bike.model} model. ` +
`Rent by the hour from €${bike.pricePerHour.toFixed(2)} on CicloUrbano.`;
return {
title,
description,
alternates: { canonical: `/bicicletas/${bike.id}` },
openGraph: {
title,
description,
type: 'website',
url: `https://ciclourbano.test/bicicletas/${bike.id}`,
images: [{ url: `/imagenes/${bike.id}.jpg`, width: 1200, height: 630 }],
},
twitter: { card: 'summary_large_image' },
};
}
export default async function BikeDetailPage({ params }) {
const { bicicletaId } = await params;
const bike = await getBike(bicicletaId);
if (!bike) notFound();
return (
<article>
<h1>{bike.model}</h1>
<StatusBadge status={bike.status} />
<dl>
<dt>Type</dt><dd>{bike.type}</dd>
<dt>Price</dt><dd>€{bike.pricePerHour.toFixed(2)}/h</dd>
<dt>Station</dt><dd>{bike.stationId}</dd>
</dl>
<BookingPanel bike={bike} />
</article>
);
}Details that matter:
generateMetadataand the component request the same data, and yetjson-serveronly gets one request: Next.js automatically deduplicates identicalfetchcalls within the same render. This is called request memoization, and it avoids having to pass data between the two.openGraphandtwitterproduce the<meta property="og:...">tags that WhatsApp, Slack, and LinkedIn read. With this,bici-002shared in a chat shows its own card.alternates.canonicalavoids duplicate content if the same detail page is reachable through several URLs.- The
titlegets combined with the root layout'stemplate: the final result isElectric Pro · €4.00/h · CicloUrbano.
To check the result without deploying anything:
- Session without
localStorage
localStorageIn the SPA, CicloUrbano's session lives in sessionSlice and is persisted in localStorage with useLocalStorage. On the server that mechanism doesn't exist, and it isn't an implementation detail: it's a logical consequence. The server renders the HTML before the browser runs anything, so it can only know about the session what arrives in the HTTP request itself.
And in an HTTP request, the session travels in cookies, which are read with cookies():
// src/app/layout.jsx (fragment)
import { cookies } from 'next/headers';
import UserMenu from '@/components/UserMenu';
export default async function RootLayout({ children }) {
// cookies() is asynchronous in Next.js 15 and makes the route dynamic.
const cookieStore = await cookies();
const session = cookieStore.get('ciclourbano_session');
const user = session ? JSON.parse(session.value) : null;
return (
<html lang="en">
<body>
<Header>
<UserMenu user={user} />
</Header>
<main>{children}</main>
<Footer />
</body>
</html>
);
}Comparing the two models:
localStorage (SPA) |
Cookies (SSR) | |
|---|---|---|
| Does the server see it? | Never | Yes, on every request |
| Can the user's name be rendered in the HTML? | No | Yes |
| Accessible from JavaScript | Always | Only if it's not HttpOnly |
| Protection against XSS | None | High with HttpOnly |
| Size | ~5 MB | ~4 KB |
| Sent on every request | No | Yes (bandwidth cost) |
The practical conclusion is twofold. First: UserMenu with the correct name already arrives in the HTML, without the "guest → Ana Ribera" flash the SPA had. Second, and more importantly: an HttpOnly cookie is more secure than localStorage, because an XSS attack can't read it from JavaScript. If the storefront and the SPA share a session, the cookie is the right mechanism for both.
Keep in mind that using cookies() makes the route dynamic, and since it's used here in the root layout, the whole app ends up rendering on every request. That's a steep price. The way to avoid it — moving the cookie read into a small component wrapped in <Suspense>, leaving the rest static — is material for 10-02 and 10-03.
- Streaming: HTML in pieces
One piece is still left unnamed. With what we've seen so far, the server waits until it has all the data, renders all the HTML, and sends it in one go. If a bike's availability takes 800 ms to compute, the user waits 800 ms seeing nothing, just like before: we've changed where the wait happens, not the fact that there is one.
Streaming breaks that: the server sends the page's frame first — header, the bike's title, footer — and sends the missing pieces as they resolve, without closing the connection. In Next.js it's turned on in two ways:
- By putting a
loading.jsxin the route's folder, which Next.js automatically turns into a<Suspense fallback={...}>around the page. - By manually wrapping the slow part in
<Suspense>inside the component.
// src/app/loading.jsx
import PageSkeleton from '@/components/PageSkeleton';
export default function Loading() {
return <PageSkeleton rows={5} />;
}You'll recognize the mechanism: it's exactly the <Suspense fallback> from 08-04, applied to data instead of code. And that's the promise left open back then: the full Suspense mechanism, HTML streaming, and the server component model underneath this whole lesson are the content of 10-03.
Common Mistakes and Tips
- Thinking SSR eliminates JavaScript. It doesn't: the bundle still downloads and the app still hydrates. What improves is when the content becomes visible, not how much it weighs. If your problem is weight, the fix is still the one from module 8.
- Using
window,document, orlocalStorageduring render. They don't exist on the server and the render fails with aReferenceError. They always go insideuseEffect. - Putting
'use client'on the root layout "so everything works." It's the fastest way to cancel out every advantage: the whole tree becomes client-side. The rule is the opposite: push the boundary as far down as possible. - Forgetting
awaitonparams,searchParams,cookies(), orheaders(). In Next.js 15 they're promises. Withoutawaityou'll get a strange object or a warning, and a lot of tutorials written before version 15 use them without awaiting. - Assuming
fetchgets cached. In Next.js 15 the default behaviour is not to cache. If you want caching, ask for it explicitly (10-02). - Rendering dates with
toLocaleString()without fixing a zone or a locale. The server is in UTC; the browser isn't. It's the number-one cause of hydration errors. Use explicittimeZoneandlocale, or format in an effect. - Migrating the whole app in one go. The public storefront and the management panel have opposite requirements. Migrate what gains from SSR and leave the rest as an SPA: that's a legitimate architecture, not a half-measure.
- Not checking the result in the raw HTML.
Ctrl+Uorcurlare the only honest way to know what actually arrives. The element inspector always shows the already-hydrated page and will give you a false sense of success. - Tip: don't carry TanStack Query to the storefront out of habit. In a server component it adds nothing, because there's no client cache to manage. It's still the right tool in the Vite SPA and in client islands that need data.
Exercises
Exercise 1. For each CicloUrbano screen, choose the most suitable rendering strategy (CSR, SSR, SSG, or ISR) and justify it in one sentence. Also state whether it belongs in ciclourbano-web or stays in the Vite SPA.
| Screen | Content |
|---|---|
Public catalogue / |
Bikes with live availability |
Detail page /bicicletas/bici-002 |
Model, price, and status; changes several times a day |
/condiciones |
Legal text; changes twice a year |
/reservas |
The signed-in user's bookings |
/taller |
Operator panel with live incidents |
/estaciones |
3 stations; the name and district don't change, free docks do |
Exercise 2. This component causes a hydration error and, on top of that, a server crash. Identify the three problems and rewrite it.
// src/components/BookingPanel.jsx
function BookingPanel({ bike }) {
const lastVisit = localStorage.getItem('lastVisit');
const reference = `REF-${Math.floor(Math.random() * 10000)}`;
const now = new Date().toLocaleTimeString();
return (
<aside>
<p>Current time: {now}</p>
<p>Booking reference: {reference}</p>
{lastVisit && <p>Your last visit: {lastVisit}</p>}
<button onClick={() => alert('Booking…')}>
Book {bike.model}
</button>
</aside>
);
}Exercise 3. Create the storefront's /estaciones/[estacionId]/incidencias route. It must: fetch incidents from http://localhost:3001/incidencias?stationId=<id> without caching, return a real 404 if the station doesn't exist, export a generateMetadata with the title Incidents at <station name>, and show a message when there are no incidents. Also state which additional files you'd create in that folder and what for.
Solutions
Solution 1.
| Screen | Strategy | Where | Justification |
|---|---|---|---|
Catalogue / |
SSR | ciclourbano-web |
Availability must be real on every visit, and the page has to be indexable |
Detail page bici-002 |
ISR | ciclourbano-web |
Changes rarely and there are hundreds of pages: prerendering and revalidating every few minutes gives the best of both |
/condiciones |
SSG | ciclourbano-web |
Fixed content: generating it on every request wastes server resources |
/reservas |
CSR | Vite SPA | Data private per user, no SEO value, lots of interaction |
/taller |
CSR | Vite SPA | Behind ProtectedRoute with operario role; nothing to index |
/estaciones |
ISR or SSR | ciclourbano-web |
The listing is nearly fixed, but free docks change: short ISR, or SSR for absolute accuracy |
Solution 2. The three problems are:
localStorage.getItemduring render: it doesn't exist on the server and the render fails with aReferenceError.Math.random(): produces a different value on the server and the client → a hydration mismatch.new Date().toLocaleTimeString(): the server is in UTC and the browser is in a different zone → another mismatch. On top of that, theonClickforces the component to be client-side.
// src/components/BookingPanel.jsx
'use client';
import { useState, useEffect, useId } from 'react';
function BookingPanel({ bike, reference }) {
// Stable identifier between server and client.
const panelId = useId();
// Deterministic initial values.
const [lastVisit, setLastVisit] = useState(null);
const [now, setNow] = useState(null);
useEffect(() => {
setLastVisit(window.localStorage.getItem('lastVisit'));
setNow(new Date().toLocaleTimeString('en-US'));
window.localStorage.setItem('lastVisit', new Date().toISOString());
}, []);
return (
<aside aria-labelledby={panelId}>
<h2 id={panelId}>Booking</h2>
{now && <p>Current time: {now}</p>}
<p>Booking reference: {reference}</p>
{lastVisit && <p>Your last visit: {lastVisit}</p>}
<button onClick={() => alert('Booking…')}>
Book {bike.model}
</button>
</aside>
);
}
export default BookingPanel;The keys to the fix: the random reference gets generated on the server and passed as a prop, so the HTML and the hydration match; storage and the time get read in useEffect, after hydration; and values that don't exist yet render conditionally so they don't produce mismatches.
Solution 3.
// src/app/estaciones/[estacionId]/incidencias/page.jsx
import { notFound } from 'next/navigation';
import NoticeList from '@/components/NoticeList';
const API = 'http://localhost:3001';
async function getStation(estacionId) {
const response = await fetch(`${API}/estaciones/${estacionId}`, { cache: 'no-store' });
return response.ok ? response.json() : null;
}
export async function generateMetadata({ params }) {
const { estacionId } = await params;
const station = await getStation(estacionId);
if (!station) return { title: 'Station not found' };
return {
title: `Incidents at ${station.name}`,
description: `Open incidents at the ${station.name} station (${station.district}).`,
};
}
export default async function IncidentsTab({ params }) {
const { estacionId } = await params;
// The two requests are independent: run them in parallel.
const [station, incidentsResponse] = await Promise.all([
getStation(estacionId),
fetch(`${API}/incidencias?stationId=${estacionId}`, { cache: 'no-store' }),
]);
if (!station) notFound();
const incidents = await incidentsResponse.json();
if (incidents.length === 0) {
return <p>No open incidents at {station.name}.</p>;
}
return <NoticeList notices={incidents} />;
}Additional files in that folder, and why:
loading.jsx: while the incidents are being fetched, the station header (which lives in the parent-levellayout) stays visible, and only the tab shows its skeleton.error.jsx: ifjson-serverfails, it gets caught there without taking down the header or the tabs; it must carry'use client'and receives theerrorandresetprops.not-found.jsx: optional, to give a specific message for a nonexistent station instead of the root's generic one.
Notice the elegance of the nesting: the three files only affect the tab's subtree, exactly like module 4's per-zone ErrorBoundarys.
Conclusion
This lesson has crossed the boundary that module 9's closing announced. The starting point was an empty <div id="root"> and three chained trips — HTML, JavaScript, data — before seeing a bike; the destination is a server that sends bici-002's HTML already built, with its title, its price, and its Open Graph tags, ready for the browser, for Google, and for a chat's preview card.
The four rendering strategies are now fixed, and they're the map for the whole module: CSR generates the HTML in the browser and serves what's private and interactive; SSR generates it on every request and serves what's fresh and indexable; SSG generates it once at build time; and ISR regenerates it every so often. And it's clear that you don't pick one for the whole project, but one per route.
On how it works, the essential thing is hydration: the server sends inert HTML and React reuses it in the browser to attach state and handlers to it. From that comes the one rule you truly need to internalize: during render, a component can only use information the server also has. Local date, Math.random(), localStorage, window, and window size live in useEffect, not in the component's body.
On tooling, ciclourbano-web is now up and running: a Next.js 15 project with the App Router that lives alongside the Vite SPA without replacing it, with an explicit split criterion: what's public, indexable, and shareable goes to the storefront — catalogue, bike detail page, stations, informational pages —; /acceso, /reservas, and /taller stay in the SPA. Routes get drawn with folders (page, layout, loading, error, not-found, [bicicletaId]), <Outlet /> becomes children, data gets requested with async/await right inside the server component itself — no useEffect, no useQuery, no manual loading states —, and params, searchParams, cookies(), and headers() are promises in Next.js 15. Dynamic rendering kicks in when the route can't be known ahead of time, and the catalogue with live availability triggers it with cache: 'no-store'. SEO stops being a problem thanks to metadata and generateMetadata, and the session moves from localStorage to cookies, which is also the safer option.
Two debts are left explicitly on the table, and both get paid soon. The first is the 'use client' directive, which you've used here as an operating rule and which 10-03 explains for what it really is: the boundary between two worlds. The second is streaming, which we've named without developing.
But first we need to close out the right half of the strategies table. Notice that throughout this lesson we've forced dynamic rendering with cache: 'no-store', as if the server had to work on every visit. For the catalogue with live availability that makes sense; for the terms of service, which change twice a year, it's an absurd waste: the same HTML is being generated thousands of times a day. The next lesson goes to the other extreme — generating the HTML only once, at build time — and then looks for the middle ground that solves 90% of real-world cases: incremental regeneration. The next lesson is Static Site Generation (SSG) with Next.js.
React Course
Module 1: Getting Started with React
- What Is React?
- Setting Up the Development Environment
- Hello World in React
- JSX: A JavaScript Syntax Extension
- How React Renders: Virtual DOM and Reconciliation
Module 2: React Components
- Understanding Components
- Function vs Class Components
- Props: Passing Data to Components
- State: Managing Component State
- Styling Components: CSS, Modules and Utilities
Module 3: Working with Events
- Handling Events in React
- Conditional Rendering
- Lists and Keys
- Forms and Controlled Components
- Form Validation and Uncontrolled Components
- Accessibility in Interactive Components
Module 4: Advanced Component Concepts
- Lifting State Up
- Composition vs Inheritance
- React Lifecycle Methods
- Hooks: Introduction and Basic Use
- Error Boundaries: Catching Failures in the UI
Module 5: React Hooks
- The useState Hook
- The useEffect Hook
- The useRef Hook and DOM Access
- The useContext Hook
- The useReducer Hook
- Custom Hooks
Module 6: Routing in React
- Introducing React Router
- Setting Up React Router
- Nested Routes
- Programmatic Navigation
- Protected Routes and Access Control
Module 7: State Management
- Introduction to State Management
- The Context API
- Redux: Introduction and Setup
- Redux: Actions and Reducers
- Redux: Connecting to React
- Server State: Fetching, Caching and Syncing
Module 8: Performance Optimization
- Performance Optimization Techniques in React
- Memoization with React.memo
- The useMemo and useCallback Hooks
- Code Splitting and Lazy Loading
- Measuring Performance with React DevTools Profiler
Module 9: Testing React Applications
- Introduction to Testing
- Unit Testing with Jest
- Component Testing with React Testing Library
- Testing Asynchronous Code and Mocking APIs
- End-to-End Testing with Cypress
Module 10: Advanced Topics
- Server-Side Rendering (SSR) with Next.js
- Static Site Generation (SSG) with Next.js
- Suspense and React Server Components
- TypeScript with React
- React Native: Building Mobile Apps
