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

  1. What a suspense boundary is
  2. What it means for a component to suspend
  3. Nesting boundaries: who shows what
  4. Suspense with lazy: what you already knew
  5. Suspense for data: React 19's use hook
  6. useSuspenseQuery: Suspense in the Vite SPA
  7. Streaming HTML
  8. Streaming in Next.js: loading.jsx and <Suspense> by hand
  9. Suspense and ErrorBoundary: covering loading and failure
  10. Transitions: keeping the fallback from wiping out what's visible
  11. React Server Components: what they are and how they differ from SSR
  12. The mixed tree and the 'use client' boundary
  13. What crosses the boundary and what doesn't
  14. Placing the boundary as low as possible
  15. Server actions: 'use server'
  16. What changes relative to modules 5 through 7, and what doesn't

  1. 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 fallback instead of my entire subtree. Once that component can render, I show the real content.

<Suspense fallback={<PageSkeleton rows={5} />}>
  <BikeList />
</Suspense>

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 fallback should take up roughly the same space as the real content. Otherwise the page jumps when it resolves. It's the same reason we preferred PageSkeleton over a "Loading…" text in 08-04.

  1. 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, no useState(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.

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

  1. Suspense with lazy: what you already knew

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

  1. Suspense for data: React 19's use hook

React 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>
  );
}
// C) An external cache manages it: that's what TanStack Query does.

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.

  1. useSuspenseQuery: Suspense in the Vite SPA

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

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

  1. Streaming in Next.js: loading.jsx and <Suspense> by hand

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

<Suspense fallback={<Loading />}>
  <Page />
</Suspense>

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 async server component that awaits a piece of data suspends: that's the link between 10-01's await and this lesson's mechanism. A server component's await is a suspension.

Result: CDN-grade TTFB, instant primary content, exact live data, and everything indexable.

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

  1. Transitions: keeping the fallback from wiping out what's visible

There'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.

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

  1. The mixed tree and the 'use client' boundary

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

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

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

  1. Does it use useState, useReducer, useEffect, useRef, or some client hook? → client.
  2. Does it have event handlers (onClick, onChange, onSubmit)? → client.
  3. Does it use browser APIs (window, localStorage, IntersectionObserver)? → client.
  4. Does it use context? → client (both the provider and the consumer).
  5. 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.

  1. Server actions: '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 of onSubmit. React intercepts the submission, serializes the FormData, 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 receives previousState as its first parameter.
  • useFormStatus is imported from react-dom and only works in a child component of the <form>. If you call it in BookingForm, pending will always be false. 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 validateBooking from 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)

  1. 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, and useCallback still 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' in layout.jsx to "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 as children or as a JSX prop instead.
  • Calling useFormStatus in the same component that declares the <form>. It always returns pending: 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 Suspense outside the ErrorBoundary. 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 fallback while filtering. Wrap the update in useTransition and give a soft cue with isPending.
  • 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

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