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
- The waste of rendering the same thing a thousand times
- What static generation actually is
- CicloUrbano inventory: what benefits from being static and what doesn't
- How Next.js decides between static and dynamic
- Reading the output of
next build: ○, ● and ƒ generateStaticParams: prerendering dynamic routesdynamicParams: what happens with a new identifier- Incremental revalidation (ISR)
- Stale while revalidating: the timeline
- On-demand revalidation:
revalidatePathandrevalidateTag - Choosing the right revalidation time
- Content from local files or a CMS
- Special static routes:
sitemap,robots, and preview images - Images and fonts:
next/imageandnext/font - When static is not the right call
- Final summary table and hybrid architecture
- 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.
- 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
fetchcalls 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.
- 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.
- 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 dynamicdynamic = '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).
- Reading the output of
next build: ○, ● and ƒ
next build: ○, ● and ƒYou don't have to guess: next build tells you exactly what it did with each route.
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.
generateStaticParams: prerendering dynamic routes
generateStaticParams: prerendering dynamic routesA 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 isbicicletaId. And the value is always a string, even if the identifier were numeric. cache: 'no-store'is gone, replaced withnext: { 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 thefetchcalls 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.
dynamicParams: what happens with a new identifier
dynamicParams: what happens with a new identifierAn 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:
| 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:
truefor 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.falsewhen 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.
- 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.
- 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: 60doesn'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.
- On-demand revalidation:
revalidatePath and revalidateTag
revalidatePath and revalidateTagTime-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.
revalidatePathon 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.
- 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.
- 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.
---
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:
fsin 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 = falseis the right call here: the set of informational pages is closed, and a made-up URL should return a 404.dangerouslySetInnerHTMLis 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
readPagefor afetchto its API, and on-demand revalidation gets triggered from the CMS's webhook to theroute.jsfrom section 10.
- Special static routes:
sitemap, robots, and preview images
sitemap, robots, and preview imagesNext.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.
- Images and fonts:
next/image and next/font
next/image and next/fontTwo 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.
- 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.
- 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 withnpm run build && npm run start. - Expecting
revalidate: 60to 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.logto debug and finding it empty. If the page is static, thatconsole.logran once, duringnext 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 bebicicletaId. And the value, a string. - Using
searchParamswithout realizing it breaks prerendering. A single read turns the page intoƒ. If you need query parameters without losing static, read them on the client withuseSearchParams. - Leaving
/api/revalidarwithout 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
widthandheightonnext/image. Without them no space gets reserved, and the content jump the component was meant to prevent comes right back. - Tip: make
next buildpart of your continuous integration. Combined withexport const dynamic = 'error'on routes that must stay static, a misconfiguredfetchstops being a production problem and becomes a build failure. - Tip: prerender only what gets visited. In large catalogues,
generateStaticParamswith the most popular items plusdynamicParams: truegets 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 = falseon/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.generateStaticParamsreadscontent/blog/withfs.readdirand returns a{ slug }per file.- In
sitemap.jsyou need to add the/blogroute and one entry per article, withlastModifiedtaken from thedatefield in the front matter — not fromnew Date(), which would mark every article as just-modified on every build and degrade the signal for search engines — andchangeFrequency: 'monthly'. - Recommended extra: an
opengraph-image.jsxat/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
- What Is React?
- Setting Up the Development Environment
- Hello World in React
- JSX: A JavaScript Syntax Extension
- How React Renders: Virtual DOM and Reconciliation
Module 2: React Components
- Understanding Components
- Function vs Class Components
- Props: Passing Data to Components
- State: Managing Component State
- Styling Components: CSS, Modules and Utilities
Module 3: Working with Events
- Handling Events in React
- Conditional Rendering
- Lists and Keys
- Forms and Controlled Components
- Form Validation and Uncontrolled Components
- Accessibility in Interactive Components
Module 4: Advanced Component Concepts
- Lifting State Up
- Composition vs Inheritance
- React Lifecycle Methods
- Hooks: Introduction and Basic Use
- Error Boundaries: Catching Failures in the UI
Module 5: React Hooks
- The useState Hook
- The useEffect Hook
- The useRef Hook and DOM Access
- The useContext Hook
- The useReducer Hook
- Custom Hooks
Module 6: Routing in React
- Introducing React Router
- Setting Up React Router
- Nested Routes
- Programmatic Navigation
- Protected Routes and Access Control
Module 7: State Management
- Introduction to State Management
- The Context API
- Redux: Introduction and Setup
- Redux: Actions and Reducers
- Redux: Connecting to React
- Server State: Fetching, Caching and Syncing
Module 8: Performance Optimization
- Performance Optimization Techniques in React
- Memoization with React.memo
- The useMemo and useCallback Hooks
- Code Splitting and Lazy Loading
- Measuring Performance with React DevTools Profiler
Module 9: Testing React Applications
- Introduction to Testing
- Unit Testing with Jest
- Component Testing with React Testing Library
- Testing Asynchronous Code and Mocking APIs
- End-to-End Testing with Cypress
Module 10: Advanced Topics
- Server-Side Rendering (SSR) with Next.js
- Static Site Generation (SSG) with Next.js
- Suspense and React Server Components
- TypeScript with React
- React Native: Building Mobile Apps
