The previous two lessons have been using a model without explaining it. You've written 'use client' because it was needed; you've accepted that a server component "doesn't get sent to the browser" without seeing how that's possible; you've dropped in a loading.jsx knowing only that there's a <Suspense> underneath it; and 10-02 closed out promising the pattern that solves nearly every hard case: a static page with a dynamic gap inside it. This lesson goes to that model. And along the way it settles an explicit debt from module 8, where we said that "Suspense is much more than a lazy-loading fallback, and its full story is in 10-03." Here it is. You're going to understand what a suspense boundary is and what it precisely means for a component to "suspend," how it suspends over data and not just code, how the server sends HTML in pieces, and what React Server Components are: where each one runs, what travels over the wire, and what can and can't cross the boundary between server and client. The focus is the model, not Next.js's functions.
Contents
- What a suspense boundary is
- What it means for a component to suspend
- Nesting boundaries: who shows what
- Suspense with
lazy: what you already knew - Suspense for data: React 19's
usehook useSuspenseQuery: Suspense in the Vite SPA- Streaming HTML
- Streaming in Next.js:
loading.jsxand<Suspense>by hand SuspenseandErrorBoundary: covering loading and failure- Transitions: keeping the
fallbackfrom wiping out what's visible - React Server Components: what they are and how they differ from SSR
- The mixed tree and the
'use client'boundary - What crosses the boundary and what doesn't
- Placing the boundary as low as possible
- Server actions:
'use server' - What changes relative to modules 5 through 7, and what doesn't
- What a suspense boundary is
A suspense boundary is a <Suspense> component placed in the tree. Its job is simple to state:
If some component below me announces that it still can't render, I show my
fallbackinstead of my entire subtree. Once that component can render, I show the real content.
It's the same idea as an error boundary from 04-05, with two important differences:
ErrorBoundary |
<Suspense> |
|
|---|---|---|
| What it catches | An error thrown below | A wait declared below |
| State it shows | Failure UI | Loading fallback |
| Is it recoverable? | Only with an explicit reset |
Yes, automatically once it resolves |
| Does it have to be written as a class? | Yes | No, it's a regular React component |
And they share the property that makes them useful: they're declarative and placed by zone. You don't ask "is it loading?" in every component; you declare once, up top, what shows while the zone isn't ready. That's exactly what eliminates the if (isPending) return <LoadingIndicator /> scattered everywhere that CicloUrbano had in module 7.
Two properties of the fallback worth pinning down from the start:
- The suspended subtree's state isn't lost if it had already mounted. When it suspends again, React hides the content instead of unmounting it, and the state is preserved.
- The
fallbackshould take up roughly the same space as the real content. Otherwise the page jumps when it resolves. It's the same reason we preferredPageSkeletonover a "Loading…" text in 08-04.
- What it means for a component to suspend
Here's the mechanism, and it deserves precision because almost everything else follows from it.
A component suspends when, during its render, it tries to read a resource that isn't available yet. Instead of returning JSX, what it does is throw the promise for that resource (in React 19, the mechanism is encapsulated and you don't write it yourself). React catches that signal, stops rendering that subtree, walks up to the nearest <Suspense>, and renders its fallback. When the promise resolves, React retries the render of the component, this time with the value already available.
sequenceDiagram
participant R as React
participant S as Suspense
participant C as Component
participant P as Promise
R->>C: render()
C->>P: read resource
P-->>C: not ready yet
C-->>R: SUSPENDS (throws the promise)
R->>S: shows the fallback
Note over R: React subscribes to the promise
P-->>R: resolved
R->>C: render() again
C-->>R: JSX with the data
R->>S: replaces the fallback with the content
Four consequences that tend to surprise people:
- The component renders at least twice: the first suspends, the second produces content. That's why the render still has to be pure, just as established in module 4: it can't have side effects.
- There's no loading state in the component. No
isPending, nouseState(true). It's an ancestor who decides what's seen during the wait. The component just says "not yet." - The component doesn't even know it suspended. There's no API to ask. This is deliberate: it separates the logic from the presentation of the wait.
- A component can't suspend on its own if it creates the promise in its own render. That's the most common mistake, and we'll see it in section 5: on retry it would create a new promise, suspend again, and enter an infinite loop.
- Nesting boundaries: who shows what
Boundaries nest, and there's a single rule: the nearest one upward suspends. That's the whole logic, and it precisely defines the zone that gets replaced by the fallback.
Consider the storefront's bike detail page:
<Suspense fallback={<PageSkeleton />}>
<Header />
<BikeData bicicletaId="bici-002" />
<Suspense fallback={<p>Checking availability…</p>}>
<LiveAvailability bicicletaId="bici-002" />
</Suspense>
<Suspense fallback={<p>Loading station…</p>}>
<StationCard estacionId="est-01" />
</Suspense>
</Suspense>flowchart TB
S1["OUTER Suspense<br/>fallback: PageSkeleton"]
S1 --> CAB["Header"]
S1 --> DB["BikeData<br/>(can suspend)"]
S1 --> S2["Suspense<br/>fallback: 'Checking availability'"]
S1 --> S3["Suspense<br/>fallback: 'Loading station'"]
S2 --> DIS["LiveAvailability<br/>(can suspend)"]
S3 --> TE["StationCard<br/>(can suspend)"]
What happens in each case:
| Who suspends | What zone gets replaced | What stays visible |
|---|---|---|
BikeData |
Everything, down to the header | Nothing on the detail page |
LiveAvailability |
Only its block | Header, data, and station |
StationCard |
Only its block | Header, data, and availability |
| All three at once | Everything (the outer one wins) | Nothing |
This is where the design rule that governs this technique comes from:
A suspense boundary defines a unit of waiting. Put boundaries around the parts that can take a while and that you don't want blocking the rest; leave out of them whatever is fast and gives the page its structure.
Putting everything under a single <Suspense> at the root is equivalent to going back to the blank screen: the user waits for the slowest thing. Putting a boundary around every single element is the opposite extreme, and it produces a page that appears in little pieces, with constant jumps. The right point is usually one unit per block of content that makes sense on its own.
- Suspense with
lazy: what you already knew
lazy: what you already knewThis is the case from 08-04, and now it's clear why it worked.
import { lazy, Suspense } from 'react';
const WorkshopPage = lazy(() => import('./pages/WorkshopPage.jsx'));
<Suspense fallback={<PageSkeleton />}>
<WorkshopPage />
</Suspense>lazy returns a component that, on its first render, checks whether the module has already downloaded. If it hasn't, it triggers the dynamic import() and suspends with that promise. React shows the fallback, and when the chunk arrives, it retries the render with the real component.
In other words: lazy isn't a separate mechanism, it's the first consumer of Suspense. The wait is over code. What comes next is the same mechanics with a wait over data.
- Suspense for data: React 19's
use hook
use hookReact 19 introduces use, which reads a promise's value during render and suspends if it isn't resolved yet.
import { use } from 'react';
function LiveAvailability({ availabilityPromise }) {
// If the promise isn't resolved, this component SUSPENDS here.
const availability = use(availabilityPromise);
return (
<p>
{availability.free} of {availability.total} units available
at {availability.station}
</p>
);
}use deliberately breaks two of the hook rules established in 04-04, and it's worth knowing:
| Hook rule | Does use follow it? |
|---|---|
| Only at the top level of the component | No: it can go inside an if or a loop |
| Only in components or custom hooks | Yes |
| Same order on every render | Doesn't apply |
Besides promises, use can also read a context (use(ThemeContext)), which lets you consume it conditionally — something useContext doesn't allow.
Now, the trap we mentioned in section 2. This enters an infinite loop:
// WRONG: creates a new promise on every render
function LiveAvailability({ bicicletaId }) {
const availability = use(
fetch(`/api/disponibilidad/${bicicletaId}`).then((r) => r.json())
);
return <p>{availability.free} units</p>;
}The sequence of the disaster: render 1 creates promise A and suspends → A resolves → React retries → render 2 creates a different promise B, which also isn't resolved → it suspends again → and so on indefinitely.
The promise has to be created outside the component that consumes it. There are three legitimate ways:
// A) A SERVER component creates it and passes it as a prop, WITHOUT awaiting.
// This is the Next.js pattern: the server doesn't wait, it delegates the wait.
export default async function BikeDetailPage({ params }) {
const { bicicletaId } = await params;
const bike = await getBike(bicicletaId); // this IS awaited
const availabilityPromise = getAvailability(bicicletaId); // NOT awaited
return (
<article>
<h1>{bike.model}</h1>
<Suspense fallback={<p>Checking availability…</p>}>
<LiveAvailability availabilityPromise={availabilityPromise} />
</Suspense>
</article>
);
}// B) A client ancestor memoizes it with useMemo (a legitimate use from module 8).
function AvailabilityPanel({ bicicletaId }) {
const promise = useMemo(() => getAvailability(bicicletaId), [bicicletaId]);
return (
<Suspense fallback={<p>Checking…</p>}>
<LiveAvailability availabilityPromise={promise} />
</Suspense>
);
}Pattern A is the important one and deserves emphasis: the server component doesn't wait for the slow data; it passes the promise to a child wrapped in <Suspense> and keeps on rendering. That is, literally, the "static page with a dynamic gap" pattern that 10-02 left as a promise.
useSuspenseQuery: Suspense in the Vite SPA
useSuspenseQuery: Suspense in the Vite SPANone of the above is exclusive to Next.js. In the Vite management app, TanStack Query offers the same integration with its cache, which solves the promise-identity problem on its own.
Compare the two styles on the same component:
// Module 7's style: the loading state lives INSIDE the component.
function BikeList() {
const { data, isPending, isError } = useQuery({
queryKey: ['bikes'],
queryFn: getBikes,
});
if (isPending) return <LoadingIndicator />;
if (isError) return <Notice tone="error">Couldn't load the bikes.</Notice>;
return <ul>{data.map((b) => <BikeCard key={b.id} bike={b} />)}</ul>;
}// Style with Suspense: the component only knows the success case.
import { useSuspenseQuery } from '@tanstack/react-query';
function BikeList() {
const { data } = useSuspenseQuery({
queryKey: ['bikes'],
queryFn: getBikes,
});
return <ul>{data.map((b) => <BikeCard key={b.id} bike={b} />)}</ul>;
}And the wait and the failure get declared outside, just once:
// src/pages/CataloguePage.jsx
<ErrorBoundary title="Couldn't load the catalogue">
<Suspense fallback={<PageSkeleton rows={5} />}>
<BikeList />
</Suspense>
</ErrorBoundary>An honest comparison of the two approaches:
useQuery |
useSuspenseQuery |
|
|---|---|---|
data can be undefined |
Yes, you have to check it | No: there's always data |
| Loading and error states | Inside the component | In external boundaries |
| Conditional branches | Three (loading, error, success) | One |
| Can it be called conditionally? | Yes, with enabled |
No: it always runs |
| Risk of waterfalls | Low | High: two sibling children that fetch in series |
That last point is the real danger. If BikeCard and BookingPanel each use their own useSuspenseQuery and sit inside the same boundary, the second query doesn't start until the first one finishes, because the second component never gets to render. The fix is to prefetch in the ancestor with queryClient.prefetchQuery or use useSuspenseQueries. It's the Suspense version of the Promise.all you already saw in 10-01.
- Streaming HTML
With classic SSR, the server does this: waits for all the data, renders all the HTML, and sends it all at once. If bici-002's availability takes 800 ms, the user stares at a blank screen for 800 ms. We've moved the wait from the browser to the server, but the wait is still there.
Streaming turns that single response into a stream. The server opens the connection, sends the page's frame with the fallbacks in place, and keeps sending chunks as each <Suspense> resolves, without closing the response.
sequenceDiagram
participant N as Browser
participant S as Next.js Server
participant API as API
N->>S: GET /bicicletas/bici-002
S->>API: bike data (fast)
API-->>S: JSON
S-->>N: CHUNK 1 · header + model + availability skeleton
Note over N: Content VISIBLE at ~200 ms
S->>API: live availability (slow)
API-->>S: JSON (800 ms later)
S-->>N: CHUNK 2 · block HTML + script that places it
Note over N: The skeleton gets replaced · no reload
S-->>N: end of response
The technical detail that makes this work, and that tends to generate disbelief: chunk 2 arrives out of order within the HTML, in a <template> at the end of the document, accompanied by a small inline script that moves it into the right slot. It's a mechanism of React itself, not Next.js, and it works even if the app's JavaScript hasn't loaded yet: the inline script is a few lines independent of the bundle.
What the user gains, in 10-01's metrics:
| Metric | SSR without streaming | SSR with streaming |
|---|---|---|
| TTFB | Waits for the slowest data | Immediate |
| FCP | At the very end | With the first chunk |
| LCP | At the very end | As soon as its block arrives |
| What the user sees | Blank, then everything at once | Structure, then it fills in |
And a less obvious implication: hydration is progressive too. React can hydrate the parts that have already arrived without waiting for the rest, and it prioritizes the zone the user is interacting with. That "uncanny valley" between seeing and being able to touch that we described in 10-01 narrows considerably.
- Streaming in Next.js:
loading.jsx and <Suspense> by hand
loading.jsx and <Suspense> by handWith the model understood, the two ways of turning it on in Next.js stop being magic.
Option 1: loading.jsx. Next.js automatically wraps that folder's page.jsx in a <Suspense> whose fallback is whatever loading.jsx exports.
// src/app/bicicletas/[bicicletaId]/loading.jsx
import PageSkeleton from '@/components/PageSkeleton';
export default function Loading() {
return <PageSkeleton rows={3} />;
}It's equivalent to writing this in the parent layout:
Coarse-grained: the whole page waits. It's fine for general navigation, but it doesn't distinguish what's fast from what's slow.
Option 2: <Suspense> by hand, which is the one that gives you the good pattern.
// src/app/bicicletas/[bicicletaId]/page.jsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import StatusBadge from '@/components/StatusBadge';
import LiveAvailability from '@/components/LiveAvailability';
const API = 'http://localhost:3001';
async function getBike(id) {
const r = await fetch(`${API}/bicicletas/${id}`, { next: { revalidate: 60 } });
return r.ok ? r.json() : null;
}
// A server component that DOES wait. When it suspends, it triggers the Suspense above.
async function AvailabilityBlock({ bicicletaId }) {
const r = await fetch(`${API}/disponibilidad/${bicicletaId}`, { cache: 'no-store' });
const availability = await r.json();
return (
<p aria-live="polite">
{availability.free} units free right now at {availability.station}
</p>
);
}
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} />
<p>€{bike.pricePerHour.toFixed(2)}/h · {bike.type}</p>
<Suspense fallback={<p>Checking availability…</p>}>
<AvailabilityBlock bicicletaId={bicicletaId} />
</Suspense>
</article>
);
}Here it is, complete at last: the pattern 10-02 left hanging.
- The bike's cacheable data is requested with
revalidate: 60, so most of the page is served prerendered. - The live availability, which would force the entire page to be dynamic, stays isolated inside the
<Suspense>. Next.js prerenders the rest and injects that gap on every request. - An
asyncserver component that awaits a piece of data suspends: that's the link between 10-01'sawaitand this lesson's mechanism. A server component'sawaitis a suspension.
Result: CDN-grade TTFB, instant primary content, exact live data, and everything indexable.
Suspense and ErrorBoundary: covering loading and failure
Suspense and ErrorBoundary: covering loading and failure<Suspense> covers the wait. It doesn't cover failure: if the availability request returns a 500, the fallback doesn't show forever — instead, the error bubbles up looking for an error boundary. Without one, it takes down the whole tree.
The two combine by wrapping the Suspense with the ErrorBoundary, in that order:
<ErrorBoundary title="Couldn't check availability">
<Suspense fallback={<p>Checking availability…</p>}>
<AvailabilityBlock bicicletaId="bici-002" />
</Suspense>
</ErrorBoundary>Why that order and not the reverse:
| Order | What happens if it fails | What happens while it loads |
|---|---|---|
| Error outside, Suspense inside ✅ | The error catches and replaces the whole zone, fallback included |
The fallback shows |
| Suspense outside, error inside ❌ | The error boundary is inside the suspended subtree: it may not even have mounted | Unpredictable behavior |
The zone's three states end up like this, and they match exactly the three from module 7's useQuery, except declared outside the component:
flowchart LR
A["Rendering"] -->|"suspends"| B["Suspense fallback"]
B -->|"promise resolved"| C["Real content"]
B -->|"promise rejected"| D["ErrorBoundary UI"]
A -->|"synchronous error"| D
D -->|"reset()"| A
In Next.js this pair comes pre-wired by convention: loading.jsx is the Suspense and error.jsx is the same segment's error boundary, with Next.js taking care of the order. error.jsx must carry 'use client', because an error boundary needs state and a reset handler:
// src/app/bicicletas/[bicicletaId]/error.jsx
'use client';
import { useEffect } from 'react';
import { reportError } from '@/utils/monitoring';
export default function BikeDetailError({ error, reset }) {
useEffect(() => {
reportError(error, { section: 'bike-detail' });
}, [error]);
return (
<div role="alert">
<h2>We couldn't show this bike</h2>
<p>{error.message}</p>
<button onClick={reset}>Retry</button>
</div>
);
}You'll recognize reportError from utils/monitoring.js: it's the same function module 4's ErrorBoundary used. The error infrastructure gets reused as is.
- Transitions: keeping the
fallback from wiping out what's visible
fallback from wiping out what's visibleThere's an unpleasant effect that shows up as soon as you use Suspense for data. The user is looking at the catalogue with its five bikes, switches the filter to "electric," the new query suspends… and the entire catalogue disappears, replaced by the skeleton. Useful content has been swapped for a loading indicator: that's a step backward, not an improvement.
The fix is useTransition, which already showed up in 08-03:
'use client';
import { useTransition } from 'react';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
export default function TypeSelector() {
const [isPending, startTransition] = useTransition();
const router = useRouter();
const currentPath = usePathname();
const searchParams = useSearchParams();
function handleChange(event) {
const type = event.target.value;
const next = new URLSearchParams(searchParams);
if (type === 'todos') next.delete('tipo');
else next.set('tipo', type);
// Inside the transition, React does NOT replace the content already visible.
startTransition(() => {
router.push(`${currentPath}?${next}`);
});
}
return (
<label>
Bike type
<select
value={searchParams.get('tipo') ?? 'todos'}
onChange={handleChange}
disabled={isPending}
/>
{isPending && <span aria-live="polite">Updating…</span>}
</label>
);
}What exactly startTransition does: it marks that update as not urgent. If a component suspends inside it, React keeps the previous content visible instead of showing the fallback, and signals it through isPending so the developer can give a softer cue — a small indicator, a reduced opacity, the control disabled.
The distinction to keep:
| Situation | What React shows |
|---|---|
| First load: nothing before it | The Suspense's fallback |
| Update without a transition | The fallback, wiping out what's visible |
| Update inside a transition | The previous content + isPending |
Practical rule: fallback for the first time, a transition for the ones after. In Next.js, navigation with <Link> already uses transitions internally; the case you need to handle by hand is a programmatic router.push, like in this example.
- React Server Components: what they are and how they differ from SSR
We've reached the lesson's second block. And we need to start by undoing a very widespread confusion: RSC isn't SSR under another name.
- SSR is a moment: rendering the HTML on the server. A component rendered by SSR also runs later in the browser during hydration, and its code is part of the bundle.
- RSC is a place: a component that runs only on the server and never in the browser. Its code isn't included in the bundle.
The full table, which sums up the entire block:
| Server component | Client component | |
|---|---|---|
| Where it runs | Only on the server | On the server (initial SSR) and on the client |
| When it runs | At build time or on the request |
On every browser render |
| What travels over the wire | Its result (RSC payload) | Its code (JavaScript) |
| Adds to the bundle? | No, nothing | Yes |
| Does it hydrate? | No: there's nothing to hydrate | Yes |
| Can it have state? | No (useState, useReducer) |
Yes |
| Can it have effects? | No (useEffect) |
Yes |
| Can it have events? | No (onClick, onChange) |
Yes |
Can it be async? |
Yes | No (but it can use use) |
| Can it read files or the database? | Yes | No |
Can it read secrets (process.env)? |
Yes | No: they'd end up in the browser |
Can it use window, localStorage? |
No | Yes |
| Does it re-render? | Only with a new request or navigation | With every state or prop change |
The row that matters most is "what travels over the wire." A server component doesn't send HTML directly to the browser: it sends a serialized description of its tree — the RSC payload — which React on the client knows how to reconstruct and merge with the client components. That's why navigating between Next.js pages doesn't reload the page: the server returns the new payload and React updates the tree while preserving the state of the client islands.
What this means in terms of weight, applied to CicloUrbano:
| Component | Type | JavaScript sent to the browser |
|---|---|---|
BikeDetailPage |
Server | 0 KB |
StatusBadge |
Server | 0 KB |
BikeCard |
Server | 0 KB |
| The date-formatting library it uses | Server | 0 KB |
TypeSelector |
Client | ~1 KB + React |
ThemeButton |
Client | ~0.8 KB |
That bolded row is the decisive argument. A heavy dependency used only in a server component — a Markdown formatter, a syntax-highlighting library, a database client — never reaches the browser. It's the most radical solution to module 8's size problem: not splitting the code, but not sending it at all.
- The mixed tree and the
'use client' boundary
'use client' boundaryA real application isn't all server or all client: it's a mixed tree where client components are islands within a sea of server components.
The 'use client' directive, on a file's first line, marks the entry point into the client side. And here's the rule that generates the most confusion:
'use client'doesn't mark a component. It marks a boundary. Everything that module imports — and everything its imports import — also becomes client code.
flowchart TB
subgraph SERVER["SERVER zone · 0 KB to the browser"]
L["layout.jsx"] --> P["page.jsx"]
P --> LB["BikeList"]
LB --> TB["BikeCard"]
TB --> EE["StatusBadge"]
end
P --> ST["'use client'<br/>TypeSelector"]
L --> BT["'use client'<br/>ThemeButton"]
subgraph CLIENT["CLIENT zone · gets bundled and hydrated"]
ST --> UP["utils/params.js"]
BT --> CT["contexts/ThemeProvider"]
end
A very important practical consequence: putting 'use client' in the root template turns the entire application into client code. The whole tree gets bundled, and the model's advantages vanish without any warning. It's beginners' number-one mistake, and that's why section 14 is dedicated to placing the boundary correctly.
Two clarifications so as not to overcorrect in the opposite direction:
- A client component also renders on the server to produce the initial HTML. "Client" means "also runs on the client," not "only on the client." That's why 10-01's hydration rules still apply to it: no
localStorageduring render. 'use client'doesn't need to be repeated in every file of the subtree. It's enough to put it at the entry point; what gets imported inherits the condition. Adding it in extra places doesn't break anything, but it muddies where the real boundary is.
- What crosses the boundary and what doesn't
When a server component renders a client one, the props have to serialize to travel in the RSC payload. That's where a strict rule comes from.
| Prop type | Does it cross? | Note |
|---|---|---|
string, number, boolean, null, undefined |
✅ | |
| Arrays and plain objects | ✅ | If their contents are also serializable |
Date, Map, Set, BigInt, TypedArray |
✅ | React serializes them |
| Promises | ✅ | The client consumes them with use (section 5) |
| Functions | ❌ | Except server actions (section 15) |
| Classes and instances | ❌ | |
JSX elements (<p>Hi</p>) |
✅ | Including children: the key exception |
| Symbols | ❌ |
The case that breaks the most code is functions. This doesn't work:
// ERROR: you can't pass a server function to a client component
export default async function CataloguePage() {
const bikes = await getBikes();
function handleSelect(id) { // this function lives on the server
console.log(id);
}
return <InteractiveList bikes={bikes} onSelect={handleSelect} />;
}InteractiveList is a client component and handleSelect is a function: there's no way to serialize it. React throws an explicit error. The fix is for the handler to be defined inside the client component, which is where it makes sense: the server can't react to a click.
children: the exception that changes everything
Now the most important piece of the section, and the one that usually unlocks understanding of the model.
It seems like a client component can only contain client components: if it imports a component, that component crosses over to its side of the boundary. But there's an escape hatch:
A server component can be passed as
children(or as any JSX-typed prop) to a client component.
It works because the one that renders it is the server parent, not the client component. The client component receives the already-rendered result and just drops it into a slot. It never imports its code, so that code doesn't get bundled.
// src/components/Panel.jsx — CLIENT: has state (collapse/expand)
'use client';
import { useState } from 'react';
export default function Panel({ title, children }) {
const [open, setOpen] = useState(true);
return (
<section>
<button onClick={() => setOpen(!open)} aria-expanded={open}>
{title}
</button>
{open && <div>{children}</div>}
</section>
);
}// src/app/estaciones/[estacionId]/page.jsx — SERVER
import Panel from '@/components/Panel';
import BikeList from '@/components/BikeList'; // a server component!
export default async function FleetTab({ params }) {
const { estacionId } = await params;
const bikes = await getFleet(estacionId);
return (
<Panel title="Parked fleet">
{/* BikeList is a SERVER component living inside a CLIENT component */}
<BikeList bikes={bikes} />
</Panel>
);
}What gets sent to the browser is Panel (1 KB with its useState) and the already-rendered HTML of the list. BikeList, BikeCard, StatusBadge, and the formatting logic stay on the server. With the naive approach — importing BikeList inside Panel.jsx — that whole subtree would have crossed the boundary.
And notice this isn't a new technique: it's module 4's composition, "composition versus inheritance," with children as the slot. That lesson championed the pattern for flexibility and decoupling; in the RSC model it now also has a direct consequence on the bundle's weight.
- Placing the boundary as low as possible
The rule fits in one sentence: 'use client' goes as close to the interactivity as possible.
The procedure for applying it to any component:
- Does it use
useState,useReducer,useEffect,useRef, or some client hook? → client. - Does it have event handlers (
onClick,onChange,onSubmit)? → client. - Does it use browser APIs (
window,localStorage,IntersectionObserver)? → client. - Does it use context? → client (both the provider and the consumer).
- If it's none of the above → server, even if it "looks like" a normal component.
Applied to CicloUrbano's catalogue:
| Component | Type | Reason |
|---|---|---|
CataloguePage |
Server | Only requests data and composes |
BikeList |
Server | Only iterates an array |
BikeCard |
Server | Only renders; the link is a <Link>, which needs no state |
StatusBadge |
Server | Only maps status to color and text |
TypeSelector |
Client | onChange + useRouter |
ThemeButton |
Client | useContext + onClick |
UserMenu |
Client | Open state + outside onClick |
Modal, BookingDialog |
Client | State, focus, Escape key |
FleetSummary |
Server | Pure computation over the data |
The interesting case is BikeCard. It's tempting to mark it client because "it's interactive": you can click it. But what gets clicked is a <Link>, and <Link> manages its own interactivity. The card itself has no state of its own.
If a "save to favorites" button were needed later, the right solution isn't to mark the whole card as client, but to extract the button:
// src/components/BikeCard.jsx — SERVER
import Link from 'next/link';
import StatusBadge from './StatusBadge';
import FavoriteButton from './FavoriteButton'; // client, minimal island
import styles from './BikeCard.module.css';
export default function BikeCard({ bike }) {
return (
<article className={styles.card}>
<Link href={`/bicicletas/${bike.id}`}>
<h3>{bike.model}</h3>
</Link>
<StatusBadge status={bike.status} />
<p>€{bike.pricePerHour.toFixed(2)}/h</p>
<FavoriteButton bicicletaId={bike.id} />
</article>
);
}// src/components/FavoriteButton.jsx — CLIENT, and only this
'use client';
import { useLocalStorage } from '@/hooks/useLocalStorage';
export default function FavoriteButton({ bicicletaId }) {
const [favorites, setFavorites] = useLocalStorage('favorites', []);
const isFavorite = favorites.includes(bicicletaId);
function handleClick() {
setFavorites(
isFavorite
? favorites.filter((id) => id !== bicicletaId)
: [...favorites, bicicletaId]
);
}
return (
<button onClick={handleClick} aria-pressed={isFavorite}>
{isFavorite ? '★ Saved' : '☆ Save'}
</button>
);
}With twenty cards on screen, the browser gets twenty instances of a 300-byte button instead of twenty complete cards with their styles, their logic, and their dependencies. And useLocalStorage, module 5's own hook, gets reused as is: it's client-side, and now it's on the correct side of the boundary.
- Server actions:
'use server'
'use server'The way back is still missing. Server components render data, but how do you send data from the browser without writing an endpoint, a fetch, and its state management?
A server action is an async function marked with 'use server' that is defined on the server and can be invoked from the client. React and the framework handle the transport: the client receives a reference, not the code.
// src/actions/bookings.js
'use server';
import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';
import { validateBooking } from '@/utils/validateBooking';
export async function createBooking(previousState, formData) {
// 1. Authorization ON THE SERVER. Never trust the client.
const cookieStore = await cookies();
const session = cookieStore.get('ciclourbano_session');
if (!session) {
return { ok: false, errors: { general: 'You need to sign in.' } };
}
// 2. Extract the data from the FormData.
const data = {
bicicletaId: formData.get('bicicletaId'),
startDate: formData.get('startDate'),
hours: Number(formData.get('hours')),
};
// 3. VALIDATE ON THE SERVER, with the same utility from module 3.
const bikes = await fetch('http://localhost:3001/bicicletas').then((r) => r.json());
const errors = validateBooking(data, bikes);
if (Object.keys(errors).length > 0) {
return { ok: false, errors, data };
}
// 4. Write.
const response = await fetch('http://localhost:3001/reservas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...data, user: JSON.parse(session.value).id, status: 'activa' }),
});
if (!response.ok) {
return { ok: false, errors: { general: 'Could not create the booking.' } };
}
// 5. Invalidate the affected cache (10-02).
revalidateTag(`bike-${data.bicicletaId}`);
revalidateTag('bikes');
return { ok: true, errors: {} };
}And the form that uses it, with the two React 19 hooks built for this:
// src/components/BookingForm.jsx
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { createBooking } from '@/actions/bookings';
function SubmitButton() {
// useFormStatus reads the state of the ANCESTOR <form>:
// that's why it has to be in a child component, not the one that declares the form.
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Booking…' : 'Confirm booking'}
</button>
);
}
export default function BookingForm({ bike }) {
const [state, formAction, isSubmitting] = useActionState(createBooking, {
ok: false,
errors: {},
});
return (
<form action={formAction}>
<input type="hidden" name="bicicletaId" value={bike.id} />
<label>
Start date
<input type="datetime-local" name="startDate" required />
</label>
{state.errors.startDate && (
<p role="alert">{state.errors.startDate}</p>
)}
<label>
Hours
<input type="number" name="hours" min="1" max="8" defaultValue="1" />
</label>
{state.errors.hours && <p role="alert">{state.errors.hours}</p>}
{state.errors.general && <p role="alert">{state.errors.general}</p>}
{state.ok && <p role="status">Booking confirmed.</p>}
<SubmitButton />
</form>
);
}What's important to understand about this code:
action={formAction}instead ofonSubmit. React intercepts the submission, serializes theFormData, and calls the server action. If the JavaScript hasn't loaded yet, the form submits like a plain old HTML form and works just the same: it's real progressive enhancement.useActionState(action, initialState)returns[state, wrappedAction, isSubmitting]. The state is whatever the action returns, and that's why the action receivespreviousStateas its first parameter.useFormStatusis imported fromreact-domand only works in a child component of the<form>. If you call it inBookingForm,pendingwill always befalse. It's the most common mistake with this hook.- Validation happens on the server. You can duplicate it on the client for immediate feedback — and
validateBookingfrom module 3 works in both places — but the server-side check isn't optional.
Security warning. A server action is a public HTTP endpoint. React generates a URL for it, and anyone can invoke it with any payload. Every action must authenticate, authorize, and validate on its own, exactly as you would in a REST API. The fact that the function is written next to the component protects it from nothing.
Comparison with what the SPA did in module 7:
| SPA (Vite) | Server action | |
|---|---|---|
| Defining the endpoint | json-server or a custom API |
The function itself |
| Calling it | fetch in a mutationFn |
action={formAction} |
| Submission state | isPending from useMutation |
useFormStatus / useActionState |
| Invalidating the cache | queryClient.invalidateQueries |
revalidateTag |
| Without JavaScript | Doesn't work | Works |
| Where it validates | Client and server (two codebases) | Server (one codebase, reusable) |
- What changes relative to modules 5 through 7, and what doesn't
An honest close, because by this point it's fair to ask what's still standing from everything you've learned.
What doesn't change at all:
- JSX, props, composition, lists, and keys. Identical on both sides of the boundary.
- All the hooks, inside client components.
useState,useEffect,useRef,useReducer,useContext,useMemo,useCallback, and CicloUrbano's own hooks work exactly the same. - The error boundaries from 04-05, now paired with
Suspense. - The hydration rules, which are actually stricter than before.
- Module 3's accessibility and module 9's tests:
data-testid, roles, and Testing Library queries are still the way to test the UI. - Module 8's performance work:
memo,useMemo, anduseCallbackstill apply inside client islands.
What changes location:
| Module | Tool | Where it lands in the RSC model |
|---|---|---|
| 5 | useEffect for fetching data |
Replaced by async/await on the server |
| 6 | React Router | Replaced by folder-based routing |
| 7 | Context | Only in client components; the provider carries 'use client' |
| 7 | Redux Toolkit | Only for client state; server state no longer needs it |
| 7 | TanStack Query | Unnecessary in server components; essential in client islands and in the SPA |
| 8 | lazy + Suspense |
Still in effect for client code; doesn't apply to server code, because it's never sent |
And the right way to read that table: none of what you've learned has been wasted. In CicloUrbano, the Vite SPA with Redux Toolkit, TanStack Query, and React Router is still the main project, and the Next.js storefront is a public layer on top of it. What this module contributes is judgment: knowing there are two models, which one suits each screen, and that most of the knowledge transfers between the two.
Common Mistakes and Tips
- Creating the promise inside the component that calls
use(). Guaranteed infinite loop. The promise should be created by an ancestor, a server component, or a cache. - Putting
'use client'inlayout.jsxto "fix" an error. Turns the whole application into client code and voids the model. Find the specific component that needs interactivity and mark that one instead. - Passing a function as a prop from server to client. It isn't serializable. The handler should be defined inside the client component, or turned into a server action.
- Importing a server component inside a file with
'use client'. It stops being a server component. Pass it aschildrenor as a JSX prop instead. - Calling
useFormStatusin the same component that declares the<form>. It always returnspending: false. It has to go in a child. - Trusting client-side validation inside a server action. The action is a public endpoint: always authenticate, authorize, and validate on the server.
- Putting the
Suspenseoutside theErrorBoundary. The right order is error outside, suspense inside. - Using a single
<Suspense>at the root. Equivalent to waiting for the slowest thing, and it wastes streaming. One boundary per block that makes sense on its own. - Replacing visible content with a
fallbackwhile filtering. Wrap the update inuseTransitionand give a soft cue withisPending. - Tip: think of the tree in two colors. Mentally paint one color on whatever only displays data and another color on whatever reacts to the user. The boundary sits exactly where the color changes, and it's almost always lower than it seems.
- Tip: check the result with the network inspector. In the network tab, a well-placed server component makes the route's JavaScript visibly drop. It's this model's equivalent of 08-05's Profiler.
Exercises
Exercise 1. For each of these snippets, say whether it's correct and, if it isn't, explain the mistake and fix it.
// A
'use client';
import { cookies } from 'next/headers';
export default async function UserMenu() {
const session = (await cookies()).get('ciclourbano_session');
return <span>{session ? JSON.parse(session.value).name : 'Guest'}</span>;
}// B
export default async function CataloguePage() {
const bikes = await getBikes();
return (
<FilterableList
bikes={bikes}
onFilter={(type) => bikes.filter((b) => b.type === type)}
/>
);
}// C
function StationData({ estacionId }) {
const station = use(fetch(`/api/estaciones/${estacionId}`).then((r) => r.json()));
return <h2>{station.name}</h2>;
}Exercise 2. bici-002's detail page has three blocks with very different timings: the bike's data (30 ms, cached), live availability (800 ms), and the last three user ratings (1,500 ms). Design the <Suspense> and ErrorBoundary structure that gives the best possible experience, write the page's code, and explain in what order the user sees each thing.
Exercise 3. This BookingsPanel is a client component and drags half the catalogue into the browser. Reorganize it by applying the lowest-boundary rule and the children exception, and state which code stops being sent.
'use client';
import { useState } from 'react';
import BookingsList from './BookingsList';
import FleetSummary from './FleetSummary';
import StatusBadge from './StatusBadge';
import { formatLongDate } from '../utils/dates'; // 45 KB
export default function BookingsPanel({ bookings, fleet }) {
const [tab, setTab] = useState('active');
const visible = bookings.filter((r) =>
tab === 'active' ? r.status === 'activa' : r.status !== 'activa'
);
return (
<section>
<button onClick={() => setTab('active')}>Active</button>
<button onClick={() => setTab('history')}>History</button>
<FleetSummary fleet={fleet} />
<BookingsList bookings={visible} format={formatLongDate} />
<StatusBadge status="disponible" />
</section>
);
}Solutions
Solution 1.
A — Incorrect. Two chained mistakes: a client component can't be async and can't use cookies(), which is a server API. The fix is to remove 'use client' and leave it as a server component; it needs no interactivity at all.
// Without 'use client': server component
import { cookies } from 'next/headers';
export default async function UserMenu() {
const session = (await cookies()).get('ciclourbano_session');
return <span>{session ? JSON.parse(session.value).name : 'Guest'}</span>;
}If it also needed a stateful dropdown, that dropdown would get extracted into a client component that receives the already-resolved name as a prop.
B — Incorrect. onFilter is a function defined in a server component and passed to a client one: it isn't serializable. Besides, filtering is UI logic and belongs on the client.
// Server: only passes serializable data
export default async function CataloguePage() {
const bikes = await getBikes();
return <FilterableList bikes={bikes} />;
}// Client: the filtering lives here
'use client';
import { useState } from 'react';
export default function FilterableList({ bikes }) {
const [type, setType] = useState('todos');
const visible = type === 'todos'
? bikes
: bikes.filter((b) => b.type === type);
// ...
}A preferable alternative on the storefront: keep the filter in the URL with ?tipo= and filter on the server, as in 10-01.
C — Incorrect. The promise is created inside the component itself: every retry creates a new promise and the component suspends forever. It's also missing the <Suspense> that would show the fallback.
// The parent creates the promise and doesn't await it.
export default async function StationDetailPage({ params }) {
const { estacionId } = await params;
const stationPromise = getStation(estacionId); // no await
return (
<Suspense fallback={<p>Loading station…</p>}>
<StationData stationPromise={stationPromise} />
</Suspense>
);
}
function StationData({ stationPromise }) {
const station = use(stationPromise);
return <h2>{station.name}</h2>;
}Solution 2. Each slow block gets its own boundary, and each boundary is paired with its own error boundary so that a ratings failure doesn't take down availability.
// src/app/bicicletas/[bicicletaId]/page.jsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import ErrorBoundary from '@/components/ErrorBoundary';
import StatusBadge from '@/components/StatusBadge';
export default async function BikeDetailPage({ params }) {
const { bicicletaId } = await params;
const bike = await getBike(bicicletaId); // 30 ms, cached
if (!bike) notFound();
return (
<article>
<h1>{bike.model}</h1>
<StatusBadge status={bike.status} />
<p>€{bike.pricePerHour.toFixed(2)}/h · {bike.type}</p>
<ErrorBoundary title="Couldn't check availability right now">
<Suspense fallback={<p>Checking availability…</p>}>
<AvailabilityBlock bicicletaId={bicicletaId} />
</Suspense>
</ErrorBoundary>
<ErrorBoundary title="Couldn't load the ratings">
<Suspense fallback={<RatingsSkeleton rows={3} />}>
<RatingsBlock bicicletaId={bicicletaId} />
</Suspense>
</ErrorBoundary>
</article>
);
}Order of appearance for the user:
| Moment | What's visible |
|---|---|
| ~50 ms | Title, status, price, and the two skeletons |
| ~850 ms | Availability fills in; ratings are still in skeleton |
| ~1,550 ms | Ratings appear |
Key decisions: the fast data is awaited with await so the page's frame arrives complete; the slow ones each go in their own boundary, so the 800 ms block isn't held back by the 1,500 ms one; and two separate error boundaries guarantee failure isolation. Putting both blocks under the same <Suspense> would make availability wait needlessly on the ratings.
Solution 3. The only thing the client needs is the tab's state. Everything else can stay on the server.
// src/components/BookingsTabs.jsx — CLIENT, minimal island
'use client';
import { useState } from 'react';
export default function BookingsTabs({ summary, active, history }) {
const [tab, setTab] = useState('active');
return (
<section>
<div role="tablist">
<button role="tab" aria-selected={tab === 'active'}
onClick={() => setTab('active')}>Active</button>
<button role="tab" aria-selected={tab === 'history'}
onClick={() => setTab('history')}>History</button>
</div>
{summary}
{tab === 'active' ? active : history}
</section>
);
}// src/app/reservas/page.jsx — SERVER
import BookingsTabs from '@/components/BookingsTabs';
import BookingsList from '@/components/BookingsList'; // server
import FleetSummary from '@/components/FleetSummary'; // server
export default async function BookingsPanel() {
const [bookings, fleet] = await Promise.all([getBookings(), getFleet()]);
const active = bookings.filter((r) => r.status === 'activa');
const history = bookings.filter((r) => r.status !== 'activa');
return (
<BookingsTabs
summary={<FleetSummary fleet={fleet} />}
active={<BookingsList bookings={active} />}
history={<BookingsList bookings={history} />}
/>
);
}What stops getting sent to the browser: BookingsList, FleetSummary, StatusBadge, and, above all, the 45 KB of utils/dates, which now runs only on the server. The client gets a few hundred bytes with the tab's useState, plus the already-rendered HTML of the three blocks.
Two nuances worth attention. First: both lists render always, even though only one is shown; if the history got very large, it would make sense to turn each tab into its own route and let Next.js request only the visible one. Second: here children is used through three named JSX props (summary, active, history), not a single children. The boundary exception applies equally to any prop that holds already-rendered JSX, and this is exactly module 4's "named slots" pattern.
Conclusion
This lesson has explained the model underpinning the previous two, and it's settled the debt module 8 left open.
Of Suspense, the essential thing is the mechanism: a component that during render tries to read something not yet available suspends, and the nearest <Suspense> upward shows its fallback until the resource arrives, at which point React retries the render. Everything else follows from that: that the component has no loading state and needs none; that it's an ancestor who decides the waiting UI; that a boundary defines a unit of waiting, and that's why it's best to have one per block of content that makes sense on its own; and that the promise can never be created in the component that consumes it, on pain of an infinite loop. 08-04's lazy was simply the first consumer of this mechanism, waiting over code; React 19's use hook and useSuspenseQuery are the same thing waiting over data, and in a server component the await itself is the suspension.
Around the mechanism, three practices are now settled: HTML streaming, by which the server sends the frame and then the missing chunks without closing the connection — improving TTFB, FCP, and LCP all at once, and enabling progressive hydration —; the pairing of ErrorBoundary outside, Suspense inside, which covers a zone's three states without writing a single if; and useTransition, so that a later update doesn't wipe out content that's already visible: fallback the first time, a transition for the ones after.
Of React Server Components, what to hold onto is that RSC isn't SSR. SSR is a moment; RSC is a place. A server component runs only on the server, sends its result over the wire, and adds nothing to the JavaScript bundle, neither it nor its dependencies — the most radical solution to module 8's size problem: not splitting the code, but not sending it at all. A client component runs in both places, hydrates, and is the only one that can have state, effects, events, and browser APIs. The 'use client' directive doesn't mark a component but a boundary: everything that module imports crosses with it, and that's where the rule of placing it as low as possible comes from, with FavoriteButton as an island instead of the whole BikeCard. Serializable props and already-rendered JSX cross the boundary; functions and classes don't. And the decisive exception is children — or any named JSX prop — which lets you tuck a server component inside a client one: module 4's composition, with a new consequence for the bundle's weight.
Last, server actions with 'use server' close the way back: async functions invocable from the form with action={formAction}, with useActionState for the result and useFormStatus — in a child of the <form>, never in the same component — for the submission state; they work without JavaScript, and module 3's validateBooking gets reused intact. With one warning that admits no nuance: a server action is a public endpoint and must authenticate, authorize, and validate on its own.
And the balance sheet: nothing you've learned has gone to waste. The hooks, the composition, the error boundaries, the accessibility, the performance work, and the tests all still hold; what changes is where each thing lives. Remember also what was said in 10-02: module 11's project is built with Vite + React Router, the application you've been assembling from the start.
There's one layer left that's been mentioned twice without being developed. In 09-01, static analysis was placed at the base of the testing pyramid, as the cheapest level, and it was said that TypeScript would show up here. In this module, on top of that, you've seen contracts everywhere: which props cross a boundary, what shape the object an action returns has, what fields a Bike carries. All those contracts are implicit today: they live in the head of whoever wrote the component and only get checked when something fails at runtime. The next lesson makes them explicit and checks them as you write. The next lesson is TypeScript with React.
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
