The previous lesson left the ciclourbano-web storefront running with dynamic rendering: every visit triggers a render on the server. For the catalogue with live availability that's the right call. For the terms-of-service page, which changes twice a year, it's a waste that's hard to justify: the server generates exactly the same HTML thousands of times a day, burning CPU, adding latency, and risking downtime. This lesson goes to the other end of the module's table —generating the HTML only once, at build time— and then looks for the middle ground that solves most real-world cases: incremental regeneration. You'll learn to decide which content benefits from being static, to make Next.js prerender dynamic routes like each bike's detail page, to read the output of next build to know what it actually did, to expire and refresh HTML by time or on demand, and to close out CicloUrbano's hybrid architecture: a static storefront and a management app in the SPA.

Contents

  1. The waste of rendering the same thing a thousand times
  2. What static generation actually is
  3. CicloUrbano inventory: what benefits from being static and what doesn't
  4. How Next.js decides between static and dynamic
  5. Reading the output of next build: ○, ● and ƒ
  6. generateStaticParams: prerendering dynamic routes
  7. dynamicParams: what happens with a new identifier
  8. Incremental revalidation (ISR)
  9. Stale while revalidating: the timeline
  10. On-demand revalidation: revalidatePath and revalidateTag
  11. Choosing the right revalidation time
  12. Content from local files or a CMS
  13. Special static routes: sitemap, robots, and preview images
  14. Images and fonts: next/image and next/font
  15. When static is not the right call
  16. Final summary table and hybrid architecture

  1. The waste of rendering the same thing a thousand times

Put numbers on CicloUrbano's storefront. Assume 50,000 daily visits to the terms-of-service page, and that rendering it costs 40 ms of server CPU.

Dynamic rendering (SSR) Static generation (SSG)
Renders per day 50,000 0 (one at build time)
CPU consumed ~33 minutes daily ~40 ms, once
Typical TTFB 80-250 ms 10-30 ms from the CDN
Survives if the API goes down? No Yes
Survives a traffic spike? Only by scaling Yes, the CDN absorbs it
Hosting cost Node server always on Files on a CDN

Every row points in the same direction, and there's a single reason: the result doesn't depend on the request. When the HTML is identical for everyone and doesn't change between visits, generating it every time is repeated work.

There's an extra difference that doesn't show up in the table and that usually decides the architecture: resilience. A static page is already written to a CDN's disk. If json-server stops responding, bici-002's detail page keeps being served with the last known data. With SSR, that same page returns a 500. For a company's public storefront, staying up when the internal system fails isn't a luxury: it's what's expected.

  1. What static generation actually is

Static generation means running the components during next build, saving the resulting HTML to disk, and serving that file to everyone.

flowchart LR
    subgraph BUILD["next build · once"]
        A["Server components"] --> B["fetch to the API"]
        B --> C["React renders"]
        C --> D["HTML + RSC payload<br/>saved to disk"]
    end
    D --> E["CDN"]
    E --> F["Visitor 1"]
    E --> G["Visitor 2"]
    E --> H["Visitor 50,000"]

Three clarifications that head off common misunderstandings:

  • Static doesn't mean "no React." The prerendered HTML hydrates just like SSR's does, and client islands (TypeSelector, ThemeButton) stay interactive. The only thing that changes is when that HTML was generated.
  • Static doesn't mean "no data." The fetch calls still run; they just run on the build machine, not on every request.
  • Static doesn't mean "forever." With ISR, that HTML expires and gets regenerated. That's what we'll cover starting in section 8.

The code for a static page is identical to a dynamic one's. There's no separate API: the only thing that changes is whether you use something that forces the page to wait for the request.

// src/app/condiciones/page.jsx — a static page with nothing special going on
export const metadata = {
  title: 'Terms of service',
  description: 'CicloUrbano bike rental terms.',
};

export default function TermsPage() {
  return (
    <article>
      <h1>Terms of service</h1>
      <p>Rentals are billed in full hours from the moment of unlocking.</p>
      <h2>Deposits</h2>
      <p>Cargo bikes require a refundable €30 deposit.</p>
    </article>
  );
}

This page doesn't read cookies, doesn't read searchParams, and makes no uncached fetch calls. Next.js prerenders it without being asked.

  1. CicloUrbano inventory: what benefits from being static and what doesn't

The criteria boil down to two questions: does the result depend on who's requesting the page? and how often does it change?

Screen Depends on the user? Change frequency Strategy Why
/condiciones, /sobre-nosotros, /tarifas No Twice a year Pure SSG Fixed text; regenerating it on every visit adds nothing
/estaciones (listing) No High or low depending on what's shown ISR 5 min Name and district are fixed; free docks change
/bicicletas/[bicicletaId] (detail page) No Several times a day ISR 60 s Hundreds of pages, moderate change, high SEO value
/ (catalogue with live availability) No, but must be exact Constant SSR Showing stale availability misleads the user
/reservas Yes Constant CSR (SPA) Private data; nothing to prerender
/taller Yes, and by role too Constant CSR (SPA) Behind ProtectedRoute; no SEO value

Notice the nuance between the bike detail page and the catalogue. Both show the bike's status, but the trade-off is different: the catalogue promises availability right now, so it can't afford data that's a minute stale; the detail page is mostly a content page — model, type, price, station — and a 60-second lag on the status badge is acceptable in exchange for serving it from the CDN. The decision isn't technical, it's product: how much staleness each screen can tolerate.

  1. How Next.js decides between static and dynamic

The App Router's rule fits in one sentence:

Every route is static by default. It becomes dynamic the moment it uses something that's only known at request time.

Those "somethings" are exactly the triggers you already saw in 10-01, now shown in full:

Trigger Effect How to avoid it if you want static
fetch(..., { cache: 'no-store' }) Dynamic cache: 'force-cache' or next: { revalidate: N }
A fetch with no options (Next.js 15) Dynamic Same as above: caching has to be requested
await cookies() Dynamic Isolate it in a client component or inside <Suspense>
await headers(), connection() Dynamic Same
The searchParams prop of a page Dynamic Read the parameter in a client component with useSearchParams
export const dynamic = 'force-dynamic' Dynamic Remove it
export const revalidate = 0 Dynamic Set a value greater than zero

And the two explicit switches worth knowing:

// Forces the behavior of the whole route. Possible values:
export const dynamic = 'auto';           // default: Next.js decides
export const dynamic = 'force-static';   // forces static (cookies() returns empty)
export const dynamic = 'force-dynamic';  // forces a render on every request
export const dynamic = 'error';          // static, and ERRORS if anything makes it dynamic

dynamic = 'error' is an excellent tool in a real project: it turns "I thought this page was static" into a build failure. It's the same philosophy as module 9's static analysis, applied to rendering.

An important warning about searchParams: using it in a page makes the entire page dynamic, because the server needs the full URL. If you only want to react to ?tipo= without losing prerendering, the fix is to read the parameter in a client component and filter there, or give each filter its own route (/catalogo/electricas).

  1. Reading the output of next build: ○, ● and ƒ

You don't have to guess: next build tells you exactly what it did with each route.

npm run build
Route (app)                                Size  First Load JS  Revalidate
┌ ○ /                                     1.2 kB        102 kB
├ ○ /_not-found                            142 B         88 kB
├ ● /bicicletas/[bicicletaId]             0.9 kB        101 kB          1m
├   ├ /bicicletas/bici-001
├   ├ /bicicletas/bici-002
├   └ [+3 more paths]
├ ○ /condiciones                           136 B         88 kB
├ ● /estaciones                            310 B         89 kB          5m
├ ● /estaciones/[estacionId]               450 B         92 kB          5m
├ ƒ /catalogo                             1.1 kB        102 kB
└ ○ /sitemap.xml                           136 B         88 kB

○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand

Here's how to read that table:

Symbol Meaning When the HTML gets generated
○ Static Route with no parameters, prerendered At next build
● SSG Dynamic route prerendered with generateStaticParams At next build, one page per parameter
ƒ Dynamic Rendered on the server on every request On every visit

And the columns:

  • Size: that route's own JavaScript.
  • First Load JS: the total downloaded by whoever lands there, shared code included. It's the figure from module 8, and it still matters.
  • Revalidate: the expiry time, if there is one.

The indented lines under ● are the specific pages that got generated. If you expected an ○ and see an ƒ instead, something triggered a switch: check cookies(), searchParams, and the fetch options. This command should be part of your pre-deploy routine, just like npm test.

  1. generateStaticParams: prerendering dynamic routes

A route like /bicicletas/[bicicletaId] has an obvious problem: at build time, Next.js doesn't know which identifiers exist. It can prerender /condiciones because it's a single page, but it can't guess that there's a bici-001 and a bici-002.

generateStaticParams is the function that tells it. It runs during the build, returns the list of possible values for the dynamic segment, and Next.js generates one page per value.

// src/app/bicicletas/[bicicletaId]/page.jsx
import { notFound } from 'next/navigation';
import StatusBadge from '@/components/StatusBadge';

const API = 'http://localhost:3001';

export async function generateStaticParams() {
  const response = await fetch(`${API}/bicicletas`);
  const bikes = await response.json();

  // Each object in the array corresponds to a [bicicletaId] from the folder name.
  return bikes.map((bike) => ({ bicicletaId: bike.id }));
}

async function getBike(bicicletaId) {
  const response = await fetch(`${API}/bicicletas/${bicicletaId}`, {
    next: { revalidate: 60, tags: [`bike-${bicicletaId}`] },
  });
  return response.ok ? response.json() : null;
}

export async function generateMetadata({ params }) {
  const { bicicletaId } = await params;
  const bike = await getBike(bicicletaId);
  if (!bike) return { title: 'Bike not found' };
  return {
    title: `${bike.model} · €${bike.pricePerHour.toFixed(2)}/h`,
    description: `${bike.type} bike available for hourly rental.`,
  };
}

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 · station {bike.stationId}</p>
    </article>
  );
}

Line-by-line analysis of what changed compared to 10-01:

  • The shape of the returned value matters. Each object must have a key with the exact name of the segment: the folder is [bicicletaId], so the key is bicicletaId. And the value is always a string, even if the identifier were numeric.
  • cache: 'no-store' is gone, replaced with next: { revalidate: 60 }. This is what lets the route be static (● in the build) while still refreshing every minute.
  • The tags: ['bike-<id>'] tag doesn't do anything by itself; it's the anchor for the on-demand revalidation in section 10.
  • generateStaticParams, generateMetadata, and the component all share the fetch calls thanks to the automatic deduplication you already saw.

In nested routes, each dynamic level contributes its own function. For /estaciones/[estacionId]/incidencias, the [estacionId] generateStaticParams is inherited, and there's no need to repeat it:

// src/app/estaciones/[estacionId]/layout.jsx
export async function generateStaticParams() {
  const response = await fetch('http://localhost:3001/estaciones');
  const stations = await response.json();
  return stations.map((station) => ({ estacionId: station.id }));
}

With the canon's three stations, the build generates est-01, est-02, and est-03, each with its index tab and its incidents tab: six pages.

  1. dynamicParams: what happens with a new identifier

An inevitable question: the CicloUrbano team adds bici-006 after deployment. That page doesn't exist on disk. What happens when someone visits it?

dynamicParams decides:

// Default: true
export const dynamicParams = true;
Value Behavior for a parameter that wasn't generated
true (default) It's rendered on the server the first time, cached, and subsequent visits are served from that cache
false Returns a 404 directly, without calling the component

When to use each one:

  • true for catalogues that grow: bikes, stations, articles. This is the desired behavior in CicloUrbano: the new bike works instantly, it's just that the first visit pays for the render.
  • false when the set is closed and known: supported languages, fixed categories, legal pages. That way a made-up URL doesn't trigger a wasted render, which also closes off an avenue of abuse.

With dynamicParams = true, generateStaticParams stops being an exhaustive list and becomes a warm-up list. A very useful pattern in large catalogues is to prerender only what gets visited the most and let the rest be generated on demand:

export async function generateStaticParams() {
  const response = await fetch('http://localhost:3001/bicicletas');
  const bikes = await response.json();

  // Only the available ones: those are the ones people look up and share.
  return bikes
    .filter((bike) => bike.status === 'disponible')
    .map((bike) => ({ bicicletaId: bike.id }));
}

With CicloUrbano's canon, this generates bici-001, bici-004, and bici-005 at build time; bici-002 (rented) and bici-003 (in maintenance) will be generated the first time someone requests them.

  1. Incremental revalidation (ISR)

Here's the idea that makes static generation usable on a site with real data, and it's the module table's fourth column.

ISR (Incremental Static Regeneration) serves the saved static page, and once that page has gone more than N seconds without being regenerated, it regenerates it in the background. The visitor who arrives right after expiry doesn't wait: they get the old version, and the new one is ready for the next one.

It's declared in two ways, which can be combined:

// A) Per route: affects the whole page.
export const revalidate = 300; // seconds

// B) Per request: each fetch has its own lifecycle.
const response = await fetch(`${API}/bicicletas/${id}`, {
  next: { revalidate: 60 },
});

Differences and which one to use:

export const revalidate = N next: { revalidate: N }
Scope The whole route A specific request
Granularity Low High
When to use it The page has a single rhythm Data with different rhythms on the same page

When both are present, the shortest one wins: if the route declares 300 and a fetch declares 60, the page regenerates every 60 seconds.

A real example with two rhythms on the same page, the stations listing:

// src/app/estaciones/page.jsx
import StationCard from '@/components/StationCard';

const API = 'http://localhost:3001';

// Page cap: at most 10 minutes of staleness.
export const revalidate = 600;

export default async function StationsPage() {
  const [stations, occupancy] = await Promise.all([
    // A station's name and district never change: an hour is fine.
    fetch(`${API}/estaciones`, { next: { revalidate: 3600 } }).then((r) => r.json()),
    // Free docks change constantly: one minute.
    fetch(`${API}/ocupacion`, { next: { revalidate: 60 } }).then((r) => r.json()),
  ]);

  return (
    <section>
      <h1>Our stations</h1>
      <ul>
        {stations.map((station) => (
          <li key={station.id}>
            <StationCard
              station={station}
              freeDocks={occupancy[station.id] ?? 0}
            />
          </li>
        ))}
      </ul>
    </section>
  );
}

In the build, this route shows up as ● with Revalidate: 1m — the effective minimum — and the page is served from the CDN except for the occasional regeneration.

  1. Stale while revalidating: the timeline

This is the exact mechanism, and it's worth understanding well because it explains a very common confusion: "I changed the data, reloaded, and I'm still seeing the old thing." It's not a bug; it's the design.

With revalidate = 60 and a regeneration that takes 2 seconds:

sequenceDiagram
    participant V1 as Visitor A (t=0s)
    participant V2 as Visitor B (t=75s)
    participant V3 as Visitor C (t=78s)
    participant N as Next.js
    participant API as API

    V1->>N: GET /bicicletas/bici-002
    N-->>V1: Cached HTML (fresh) · fast
    Note over N: Cache generated at t=0. Expires at t=60.

    V2->>N: GET /bicicletas/bici-002
    N-->>V2: Cached HTML (STALE) · fast
    Note over N: Expired: regeneration triggered IN THE BACKGROUND
    N->>API: GET /bicicletas/bici-002
    API-->>N: Updated JSON
    Note over N: New cache ready at t=77

    V3->>N: GET /bicicletas/bici-002
    N-->>V3: NEW HTML · fast

What to take away:

  • Nobody ever waits. Not even visitor B, who's the one that triggers the regeneration. This pattern is called stale-while-revalidate.
  • Visitor B sees stale content, and that's a deliberate decision: a fast, slightly outdated response is preferred over a slow, exact one.
  • revalidate: 60 doesn't mean "it regenerates every 60 seconds." It means "once 60 seconds have passed, the next visit triggers the regeneration." If nobody visits for three days, nothing gets regenerated. The cache is lazy, not a timer.
  • The regeneration only happens once. If a hundred visitors arrive at once after expiry, only a single regeneration is triggered; all hundred get the old version.

This has a practical consequence: in development (npm run dev) you won't see this behavior, because dev mode always renders. To check ISR for real you need to run npm run build && npm run start. It's the same discipline as module 8's Profiler: you measure against the production build.

  1. On-demand revalidation: revalidatePath and revalidateTag

Time-based ISR solves most cases, but it leaves a gap: when the CicloUrbano team adds bici-006 or drops the price of the electric bikes, they don't want to wait 60 seconds or 10 minutes. They want the change to show up now.

On-demand revalidation flips the control: instead of the cache expiring on its own, someone invalidates it explicitly.

There are two functions, imported from next/cache:

Function What it invalidates When to use it
revalidatePath(path) The cached HTML of one specific route You know exactly which page changes
revalidateTag(tag) All requests tagged with that tag, wherever they are The data appears on several pages

Tags are declared right in the fetch call, and that's where the mechanism's power comes from:

// src/queries/storefront.js
const API = 'http://localhost:3001';

export async function getBikes() {
  const response = await fetch(`${API}/bicicletas`, {
    next: { revalidate: 300, tags: ['bikes'] },
  });
  return response.json();
}

export async function getBike(bicicletaId) {
  const response = await fetch(`${API}/bicicletas/${bicicletaId}`, {
    next: { revalidate: 60, tags: ['bikes', `bike-${bicicletaId}`] },
  });
  return response.ok ? response.json() : null;
}

Notice that getBike carries two tags: a general one and a specific one. That lets you invalidate at two different granularities: revalidateTag('bike-bici-002') affects only that detail page; revalidateTag('bikes') affects the detail page, the listing, and any other page that uses that data, without you having to enumerate them.

The trigger is a route handler (route.js), which in the App Router is the equivalent of an API endpoint. CicloUrbano's internal panel will call it whenever a bike is saved:

// src/app/api/revalidar/route.js
import { revalidateTag, revalidatePath } from 'next/cache';
import { NextResponse } from 'next/server';

export async function POST(request) {
  // 1. Authentication: without this, anyone can blow away your cache.
  const secret = request.headers.get('x-revalidation-secret');
  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: 'Not authorized' }, { status: 401 });
  }

  // 2. What needs to be invalidated.
  const { tag, path } = await request.json();

  if (tag) revalidateTag(tag);
  if (path) revalidatePath(path);

  return NextResponse.json({ revalidated: true, timestamp: Date.now() });
}

And here's how it's invoked from the management system when someone edits bici-002:

curl -X POST http://localhost:3000/api/revalidar \
  -H "Content-Type: application/json" \
  -H "x-revalidation-secret: $REVALIDATION_SECRET" \
  -d '{"tag":"bike-bici-002"}'

Three important warnings:

  • Authentication isn't optional. An open revalidation endpoint lets anyone force renders in a loop: it's a denial-of-service attack served on a silver platter.
  • revalidatePath on a dynamic route requires the second argument: revalidatePath('/bicicletas/[bicicletaId]', 'page') invalidates every detail page.
  • Invalidation doesn't regenerate immediately: it marks the cache as expired. The next visit triggers the regeneration, just like in section 9.

The same revalidateTag function can be called from a server action, which is the natural way to do it when Next.js itself manages the form. Server actions are material for 10-03.

  1. Choosing the right revalidation time

The value of revalidate isn't picked at random. The right question is: how many seconds of lag between reality and what the user sees is acceptable here?

CicloUrbano content Reasonable value Reasoning
Terms of service, "about us" false (never expires) Changes with a deployment; revalidate on demand if needed
Pricing 86400 (1 day) A price change gets communicated; a day of lag is tolerable
Station listing (fixed data) 3600 (1 hour) Name, district, and total docks barely change
Bike detail page 60 (1 minute) Good balance between freshness and cost, with a tag to force it
Free docks per station 60 (1 minute) Approximate by definition; nobody expects second-level accuracy
Catalogue with live availability 0 / SSR Here staleness really does mislead the user
Workshop panel Not applicable Private data; belongs in the SPA

Two useful heuristics:

  • Start generous and lower it if needed. A high value with on-demand revalidation as an emergency exit is usually better than a low value: it costs less and gives exact control.
  • If the right number seems to be 0, the route isn't static. Force it to dynamic and be explicit about it; don't simulate SSR with revalidate: 1, because you'll pay the complexity of both strategies without the benefit of either.

  1. Content from local files or a CMS

A server component isn't limited to fetch. Since it runs in Node, it can read the file system, something unthinkable in the SPA. It's the most direct way to manage CicloUrbano's informational pages in Markdown.

npm install gray-matter remark remark-html
content/
├── condiciones.md
├── sobre-nosotros.md
└── tarifas.md
---
title: Terms of service
description: CicloUrbano bike rental terms.
updated: 2026-03-14
---

## Billing

Rentals are billed in full hours from the moment of unlocking.
// src/app/[pagina]/page.jsx — one route for every informational page
import fs from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';
import { remark } from 'remark';
import html from 'remark-html';
import { notFound } from 'next/navigation';

const CONTENT_DIR = path.join(process.cwd(), 'content');

export async function generateStaticParams() {
  const files = await fs.readdir(CONTENT_DIR);
  return files.map((file) => ({ pagina: file.replace(/\.md$/, '') }));
}

// Closed set: any other URL must be a 404.
export const dynamicParams = false;

async function readPage(name) {
  try {
    const raw = await fs.readFile(path.join(CONTENT_DIR, `${name}.md`), 'utf8');
    const { data, content } = matter(raw);
    const processed = await remark().use(html).process(content);
    return { meta: data, html: processed.toString() };
  } catch {
    return null;
  }
}

export async function generateMetadata({ params }) {
  const { pagina } = await params;
  const page = await readPage(pagina);
  if (!page) return { title: 'Page not found' };
  return { title: page.meta.title, description: page.meta.description };
}

export default async function InformationalPage({ params }) {
  const { pagina } = await params;
  const page = await readPage(pagina);
  if (!page) notFound();

  return (
    <article>
      <h1>{page.meta.title}</h1>
      <p><small>Updated on {page.meta.updated}</small></p>
      <div dangerouslySetInnerHTML={{ __html: page.html }} />
    </article>
  );
}

Points worth highlighting:

  • fs in a component. It only works because it's a server component. If you added 'use client' to this file, the build would fail: there's no file system in the browser.
  • dynamicParams = false is the right call here: the set of informational pages is closed, and a made-up URL should return a 404.
  • dangerouslySetInnerHTML is acceptable here because the Markdown is written by the team, not an anonymous user. With third-party content you'd need to sanitize it first.
  • Swapping the files for a CMS (Contentful, Strapi, Sanity, headless WordPress) doesn't change the structure: it just swaps readPage for a fetch to its API, and on-demand revalidation gets triggered from the CMS's webhook to the route.js from section 10.

  1. Special static routes: sitemap, robots, and preview images

Next.js reserves a handful of file names that generate static artifacts at build time. These are the ones that round out the SEO work started in 10-01.

The sitemap. A file that exports a default function and produces sitemap.xml:

// src/app/sitemap.js
const BASE = 'https://ciclourbano.test';

export default async function sitemap() {
  const [bikes, stations] = await Promise.all([
    fetch('http://localhost:3001/bicicletas').then((r) => r.json()),
    fetch('http://localhost:3001/estaciones').then((r) => r.json()),
  ]);

  const staticPages = ['', '/estaciones', '/condiciones', '/tarifas'].map((path) => ({
    url: `${BASE}${path}`,
    lastModified: new Date(),
    changeFrequency: path === '' ? 'hourly' : 'monthly',
    priority: path === '' ? 1 : 0.6,
  }));

  const bikePages = bikes.map((bike) => ({
    url: `${BASE}/bicicletas/${bike.id}`,
    lastModified: new Date(),
    changeFrequency: 'daily',
    priority: 0.8,
  }));

  const stationPages = stations.map((station) => ({
    url: `${BASE}/estaciones/${station.id}`,
    changeFrequency: 'weekly',
    priority: 0.7,
  }));

  return [...staticPages, ...bikePages, ...stationPages];
}

The robots rules. This is where the architecture's split becomes explicit:

// src/app/robots.js
export default function robots() {
  return {
    rules: [
      {
        userAgent: '*',
        allow: '/',
        // Anything that lives in the SPA or is private shouldn't be crawled.
        disallow: ['/api/', '/taller', '/reservas'],
      },
    ],
    sitemap: 'https://ciclourbano.test/sitemap.xml',
  };
}

Open Graph preview images, generated at build time. In 10-01 we pointed images: [{ url: '/imagenes/bici-002.jpg' }] at a file someone had to create by hand. Next.js can draw it with ImageResponse:

// src/app/bicicletas/[bicicletaId]/opengraph-image.jsx
import { ImageResponse } from 'next/og';

export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export const alt = 'CicloUrbano bike';

export default async function PreviewImage({ params }) {
  const { bicicletaId } = await params;
  const bike = await fetch(`http://localhost:3001/bicicletas/${bicicletaId}`)
    .then((r) => r.json());

  return new ImageResponse(
    (
      <div style={{
        display: 'flex', flexDirection: 'column', justifyContent: 'center',
        width: '100%', height: '100%', padding: 80,
        background: '#12805c', color: '#ffffff', fontSize: 64,
      }}>
        <div style={{ fontSize: 32, opacity: 0.85 }}>CicloUrbano</div>
        <div style={{ fontWeight: 700 }}>{bike.model}</div>
        <div style={{ fontSize: 40 }}>
          €{bike.pricePerHour.toFixed(2)}/h · {bike.type}
        </div>
      </div>
    ),
    size,
  );
}

Notes: the JSX inside ImageResponse isn't browser React — it's a description that gets converted into a PNG — which is why it only supports a Flexbox-based subset of CSS, with display: 'flex' mandatory on containers. Since it coexists with generateStaticParams, the images get generated at build time and served from the CDN, so each bike's WhatsApp preview card is different without any per-page design work.

  1. Images and fonts: next/image and next/font

Two optimizations the storefront benefits from out of the box, and that don't exist in the Vite project without installing anything.

next/image replaces <img> and solves several performance problems at once:

import Image from 'next/image';

<Image
  src={`/imagenes/${bike.id}.jpg`}
  alt={`${bike.model} bike from CicloUrbano`}
  width={640}
  height={480}
  priority={isPrimary}
  sizes="(max-width: 768px) 100vw, 640px"
/>
What it does Why it matters
Converts to WebP/AVIF and resizes on demand Cuts the image's weight drastically
Generates srcset from sizes Mobile doesn't download the desktop version
Lazy loading by default The cards further down don't compete with the ones above
Reserves the space with width/height Eliminates content jumps (CLS)
priority disables lazy loading and preloads Improves LCP on the main image

alt is mandatory; module 3's accessibility rules still apply just the same.

next/font self-hosts fonts at build time, with no requests to external servers:

// src/app/layout.jsx (excerpt)
import { Inter } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-base',
});

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={inter.variable}>
      <body>{children}</body>
    </html>
  );
}

This downloads the font at next build, serves it from your own domain (better privacy and one less connection), and generates the descriptors needed so that the font swap doesn't move the text, which is another classic source of CLS.

  1. When static is not the right call

Static is so convenient that it's easy to overdo it. Here are the real limits:

Situation Problem What to do
200,000 pages that change daily The build takes hours and repeats on every deploy generateStaticParams with the most-visited ones + dynamicParams: true
Content personalized per user One HTML per user makes no sense SSR with cookies(), or CSR inside a client island
Data that must be accurate to the second ISR serves stale content by definition SSR with no caching
Search with free-form parameters Infinite combinations of searchParams Dynamic route, or filter on the client
Prices, stock, balances Showing stale data has legal or financial consequences SSR, or mark the critical part as dynamic
Content behind authentication You can't prerender what depends on a session SPA or SSR

There's a pattern that solves most of these cases without giving up static: a static page with a dynamic gap. bici-002's detail page gets prerendered whole — model, price, description, image — and only the live-availability block gets filled in separately, wrapped in <Suspense>. You get the CDN's TTFB and SSR's accuracy on the same page. That pattern is the heart of 10-03, and it's why the next lesson is about the model rather than about more Next.js functions.

  1. Final summary table and hybrid architecture

Closing out the map. Here's the four-strategies table applied screen by screen to CicloUrbano, with the concrete implementation:

Screen Strategy Project Implementation
/ catalogue with availability SSR ciclourbano-web fetch(..., { cache: 'no-store' })
/bicicletas/[bicicletaId] ISR 60 s ciclourbano-web generateStaticParams + next: { revalidate: 60, tags }
/estaciones ISR 1-10 min ciclourbano-web revalidate per fetch, two rhythms
/estaciones/[estacionId] (+tabs) ISR 5 min ciclourbano-web generateStaticParams in the layout
/condiciones, /tarifas, /sobre-nosotros SSG ciclourbano-web Local Markdown + dynamicParams: false
sitemap.xml, robots.txt, OG images SSG ciclourbano-web sitemap.js, robots.js, opengraph-image.jsx
/acceso CSR Vite SPA SignInPage + sessionSlice
/reservas, /reservas/nueva CSR Vite SPA TanStack Query + bookingsSlice
/taller CSR Vite SPA ProtectedRoute + RequireRole

And here's the complete architecture:

flowchart TB
    subgraph PUBLIC["Public storefront · ciclourbano-web (Next.js 15)"]
        CDN["CDN"]
        SSG["SSG: terms, pricing, sitemap, robots"]
        ISR["ISR: bike detail pages, stations"]
        SSR["SSR: catalogue with availability"]
    end

    subgraph MANAGEMENT["Management app · SPA (Vite + React Router)"]
        SPA["CSR: sign-in, bookings, workshop"]
        RTK["Redux Toolkit + TanStack Query"]
    end

    API[("API · json-server<br/>localhost:3001")]

    SSG --> CDN
    ISR --> CDN
    SSR --> API
    ISR --> API
    SPA --> RTK --> API

    CDN --> U1["Anonymous visitor<br/>and search engines"]
    SSR --> U1
    SPA --> U2["Signed-in user<br/>Ana Ribera / Marc Sole"]

    U1 -. "Sign in" .-> SPA

This architecture is common in production and deserves to be named: the storefront and the app are two projects, two deployments, and two rendering models, joined by a shared API and a shared session cookie. It isn't a half-finished transition to Next.js: it's the right decision, because the two parts have opposite requirements. The public side needs to be fast, indexable, and resilient; the private side needs to be interactive and always up to date.

About the final project. It's worth stating clearly here so there's no confusion: module 11 builds the complete app with Vite + React Router, the one you've been assembling for nine modules. Next.js is a tool you now know how to use and place, not a change of direction for the course. When you have to decide on a real project, you'll have the criteria from the table above.

Common Mistakes and Tips

  • Testing ISR with npm run dev. In development, Next.js always renders and caches nothing. ISR can only be verified with npm run build && npm run start.
  • Expecting revalidate: 60 to regenerate on its own every minute. There's no timer: regeneration is triggered by a visit after expiry. With no traffic, there's no regeneration.
  • Adding a console.log to debug and finding it empty. If the page is static, that console.log ran once, during next build, and its output is in the build log, not the server log.
  • Returning keys with the wrong name in generateStaticParams. If the folder is [bicicletaId], the key must be bicicletaId. And the value, a string.
  • Using searchParams without realizing it breaks prerendering. A single read turns the page into ƒ. If you need query parameters without losing static, read them on the client with useSearchParams.
  • Leaving /api/revalidar without authentication. It's a denial-of-service vector: anyone can force renders in a loop. A secret in the header, always.
  • Setting revalidate: 1 "just in case." That's SSR with extra complexity. If you need absolute freshness, declare the route dynamic and be explicit about it.
  • Forgetting width and height on next/image. Without them no space gets reserved, and the content jump the component was meant to prevent comes right back.
  • Tip: make next build part of your continuous integration. Combined with export const dynamic = 'error' on routes that must stay static, a misconfigured fetch stops being a production problem and becomes a build failure.
  • Tip: prerender only what gets visited. In large catalogues, generateStaticParams with the most popular items plus dynamicParams: true gets you 95% of the benefit for a fraction of the build time.

Exercises

Exercise 1. This next build isn't what was expected. The first three routes should be static or SSG and show up as dynamic instead. For each one, propose the most likely cause and the fix.

Route (app)                             Size  First Load JS
┌ ƒ /condiciones                       136 B         88 kB
├ ƒ /bicicletas/[bicicletaId]         0.9 kB        101 kB
├ ƒ /estaciones                        310 B         89 kB
└ ƒ /                                 1.2 kB        102 kB

You also know that: /condiciones contains only plain JSX text; /bicicletas/[bicicletaId] has generateStaticParams; /estaciones makes two fetch calls with no options; and / reads searchParams. Also state which of the four should legitimately stay ƒ.

Exercise 2. Design the caching strategy for /estaciones/[estacionId], which shows: name, district, and total docks (fixed); the fleet currently parked there (changes every few minutes); and open incidents (must show up as soon as an operator files one). Write the code for the requests with their revalidate and tags, and explain how you force incidents to update without waiting.

Exercise 3. The CicloUrbano team wants a blog section on the storefront: Markdown articles in content/blog/, a listing at /blog, and a detail page at /blog/[slug]. Articles get published once or twice a month. Decide the rendering strategy, the value of dynamicParams, and what needs to be added to sitemap.js, and justify each decision in one sentence.

Solutions

Solution 1.

Route Likely cause Fix
/condiciones Something global is contaminating it: almost certainly an await cookies() call in layout.jsx (the UserMenu from 10-01), or an inherited export const dynamic = 'force-dynamic' Move the cookie read out of the server layout: read it in a client component, or isolate it in a component wrapped in <Suspense>
/bicicletas/[bicicletaId] It has generateStaticParams, but the fetch calls have no caching: in Next.js 15 that's enough to make it dynamic Switch to next: { revalidate: 60, tags: [...] }
/estaciones Same reason: an option-less fetch no longer gets cached in Next.js 15 Add next: { revalidate: N } to each request
/ Reads searchParams and shows live availability Should stay ƒ. It's the storefront's one legitimate case for full SSR

The general lesson: a route that looks static and comes out as ƒ almost always has its cause in an ancestor or in a forgotten fetch option.

Solution 2.

// src/app/estaciones/[estacionId]/page.jsx
const API = 'http://localhost:3001';

export async function generateStaticParams() {
  const stations = await fetch(`${API}/estaciones`).then((r) => r.json());
  return stations.map((station) => ({ estacionId: station.id }));
}

export default async function FleetTab({ params }) {
  const { estacionId } = await params;

  const [station, fleet, incidents] = await Promise.all([
    // 1. Fixed data: an hour is more than enough.
    fetch(`${API}/estaciones/${estacionId}`, {
      next: { revalidate: 3600, tags: [`station-${estacionId}`] },
    }).then((r) => r.json()),

    // 2. Parked fleet: changes every few minutes.
    fetch(`${API}/bicicletas?stationId=${estacionId}`, {
      next: { revalidate: 120, tags: ['bikes', `fleet-${estacionId}`] },
    }).then((r) => r.json()),

    // 3. Incidents: long expiry because they're invalidated on demand.
    fetch(`${API}/incidencias?stationId=${estacionId}`, {
      next: { revalidate: 3600, tags: [`incidents-${estacionId}`] },
    }).then((r) => r.json()),
  ]);

  return (
    <>
      <p>{station.district} · {station.docks} docks</p>
      <p>{fleet.length} bikes parked</p>
      <p>{incidents.length} open incidents</p>
    </>
  );
}

The key is in the third request: it doesn't use a short revalidate, but a long one with a tag. When an operator files an incident from the workshop panel, the management system calls the revalidation endpoint:

curl -X POST http://localhost:3000/api/revalidar \
  -H "x-revalidation-secret: $REVALIDATION_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"tag":"incidents-est-02"}'

That gets you the best of both strategies: minimal cost under normal conditions (at most one regeneration per hour) and immediate updates when there's genuinely something new. Lowering revalidate to 10 seconds would give a worse and more expensive result.

Solution 3.

  • Strategy: pure SSG for both the listing and the detail page. The articles are files in the repository, so any change implies a deployment, and the deployment already rebuilds the site: time-based revalidation wouldn't add anything.
  • dynamicParams = false on /blog/[slug]: the set of articles is closed at build time, and a made-up slug should return a real 404 instead of triggering a render.
  • generateStaticParams reads content/blog/ with fs.readdir and returns a { slug } per file.
  • In sitemap.js you need to add the /blog route and one entry per article, with lastModified taken from the date field in the front matter — not from new Date(), which would mark every article as just-modified on every build and degrade the signal for search engines — and changeFrequency: 'monthly'.
  • Recommended extra: an opengraph-image.jsx at /blog/[slug] with the article's title, so sharing a link in a chat shows a card of its own.

If the articles were to move to a CMS in the future, the strategy would shift to ISR with on-demand revalidation from the CMS's webhook: the rest of the code wouldn't change.

Conclusion

This lesson completes the rendering map the module opened with. In 10-01 the HTML was generated on every request; here it gets generated once, at next build, served from a CDN, and — with ISR — refreshed only when needed.

The strategy's essentials. In the App Router every route is static by default and becomes dynamic the moment it uses something only known at request time: cookies(), headers(), searchParams, or an uncached fetch — which in Next.js 15 is the default behavior, so caching has to be requested. And nothing is left to guesswork: next build tells you, with ○ for static, ● for SSG with generateStaticParams, and ƒ for dynamic. That command belongs in the pre-deploy routine just as much as npm test.

Of the tools, four are now settled. generateStaticParams enumerates the values of a dynamic segment and generates one page per value, with the key named exactly like the folder. dynamicParams decides what happens with what wasn't there: true renders it on demand and caches it — the right call for a growing catalogue —, false returns a 404 — the right call for a closed set. revalidate, per route or per request, implements the stale-while-revalidate pattern: nobody ever waits, someone sees slightly old content, and expiry isn't a timer but a condition that triggers the next visit. And revalidateTag / revalidatePath flip the control for the cases where waiting isn't acceptable: tags declared in the fetch call, an authenticated route.js, and a call from the management system. The winning combination, which you saw in exercise 2, is a long expiry plus a tag: minimal cost at rest and immediate updates when there's news.

The storefront has also gained its complete SEO and performance layer: sitemap.js with the detail pages and stations, robots.js excluding what lives in the SPA, opengraph-image.jsx drawing a distinct card per bike at build time, next/image with its srcset, lazy loading, and reserved space, and next/font self-hosting the typeface with no text jumps. And the informational pages come from Markdown files read with fs from a server component, something that was simply impossible in the SPA.

CicloUrbano's architecture is now settled, and it's deliberately hybrid: a public storefront in ciclourbano-web — catalogue in SSR, detail pages and stations in ISR, informational pages in SSG — and a management app in the Vite SPA — sign-in, bookings, and workshop in CSR with Redux Toolkit and TanStack Query —, joined by the shared API and a shared session cookie. And with that, the reminder already made above: module 11's final project is built with Vite + React Router.

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