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

  1. What the browser gets today: an empty <div>
  2. What it means for first paint and slow connections
  3. What search engines and preview cards see
  4. The four rendering strategies
  5. What hydration is
  6. Server/client mismatches
  7. Why you need a framework
  8. Next.js 15: creating ciclourbano-web
  9. Folder-based routes: layout, page, loading, error, not-found
  10. From React Router's map to Next.js's map
  11. Server components: data with async/await
  12. The 'use client' directive, in four lines
  13. Dynamic rendering: when Next.js decides to render on every request
  14. Metadata and SEO
  15. Session without localStorage
  16. Streaming: HTML in pieces

  1. What the browser gets today: an empty <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.

  1. 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.

  1. 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:

CicloUrbano
ciclourbano.test

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.

  1. 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.

  1. 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.

  1. 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     ← client

The 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 useEffect runs after hydration, already in the browser, where window exists. 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 sets data-theme before 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.

  1. 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.

  1. Next.js 15: creating ciclourbano-web

It'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:

npx create-next-app@latest ciclourbano-web

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.

cd ciclourbano-web
npm run dev

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;
}

  1. Folder-based routes: layout, page, loading, error, not-found

In 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 a layout without 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.
  • metadata is an exported object, not a component. The template: '%s · CicloUrbano' makes any page that exports title: 'Stations' end up as Stations · 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.

  1. 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        → IncidentsTab

Compare 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:

  • params is a promise in Next.js 15. It's awaited with await. The same goes for searchParams, cookies(), and headers(). 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 nearest not-found.jsx with a real HTTP 404 status. In the SPA, NotFoundPage got 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.

  1. Server components: data with async/await

This 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:3001 would work just fine.
  • You can use secrets. An API key in process.env.API_KEY never 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.

  1. The 'use client' directive, in four lines

A 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.

  1. 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.
  • TypeSelector is a client component, but BikeList and FleetSummary are server components: they have no state, they just paint. They don't get downloaded.
  • When the filter changes, router.push triggers 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.

  1. 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:

  • generateMetadata and the component request the same data, and yet json-server only gets one request: Next.js automatically deduplicates identical fetch calls within the same render. This is called request memoization, and it avoids having to pass data between the two.
  • openGraph and twitter produce the <meta property="og:..."> tags that WhatsApp, Slack, and LinkedIn read. With this, bici-002 shared in a chat shows its own card.
  • alternates.canonical avoids duplicate content if the same detail page is reachable through several URLs.
  • The title gets combined with the root layout's template: the final result is Electric Pro · €4.00/h · CicloUrbano.

To check the result without deploying anything:

curl -s http://localhost:3000/bicicletas/bici-002 | grep -o '<meta property="og:[^>]*>'

  1. Session without localStorage

In 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.

  1. 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.jsx in 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, or localStorage during render. They don't exist on the server and the render fails with a ReferenceError. They always go inside useEffect.
  • 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 await on params, searchParams, cookies(), or headers(). In Next.js 15 they're promises. Without await you'll get a strange object or a warning, and a lot of tutorials written before version 15 use them without awaiting.
  • Assuming fetch gets 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 explicit timeZone and locale, 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+U or curl are 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:

  1. localStorage.getItem during render: it doesn't exist on the server and the render fails with a ReferenceError.
  2. Math.random(): produces a different value on the server and the client → a hydration mismatch.
  3. new Date().toLocaleTimeString(): the server is in UTC and the browser is in a different zone → another mismatch. On top of that, the onClick forces 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-level layout) stays visible, and only the tab shows its skeleton.
  • error.jsx: if json-server fails, it gets caught there without taking down the header or the tabs; it must carry 'use client' and receives the error and reset props.
  • 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

Module 2: React Components

Module 3: Working with Events

Module 4: Advanced Component Concepts

Module 5: React Hooks

Module 6: Routing in React

Module 7: State Management

Module 8: Performance Optimization

Module 9: Testing React Applications

Module 10: Advanced Topics

Module 11: Project: Building a Complete Application

© Copyright 2026. All rights reserved