La lliçó anterior va deixar l'aparador ciclourbano-web funcionant amb renderitzat dinàmic: cada visita provoca un render al servidor. Per al catàleg amb disponibilitat en viu això és correcte. Per a la pàgina de condicions del servei, que canvia dues vegades l'any, és un malbaratament difícil de justificar: el servidor genera exactament el mateix HTML milers de vegades al dia, consumeix CPU, afegeix latència i pot caure. Aquesta lliçó va a l'altre extrem de la taula del mòdul —generar l'HTML una sola vegada, en la construcció— i després busca el punt intermedi que resol la majoria dels casos reals: la regeneració incremental. Aprendràs a decidir quin contingut guanya sent estàtic, a fer que Next.js prerenderitzi rutes dinàmiques com la fitxa de cada bicicleta, a llegir la sortida de next build per saber què ha fet realment, a caducar i refrescar l'HTML per temps o sota demanda, i a tancar l'arquitectura híbrida de CicloUrbano: aparador estàtic i aplicació de gestió a la SPA.

Contingut

  1. El malbaratament de renderitzar mil vegades el mateix
  2. Què és exactament la generació estàtica
  3. Inventari de CicloUrbano: què guanya sent estàtic i què no
  4. Com decideix Next.js entre estàtic i dinàmic
  5. Llegir la sortida de next build: ○, ● i ƒ
  6. generateStaticParams: prerenderitzar rutes dinàmiques
  7. dynamicParams: què passa amb un identificador nou
  8. Revalidació incremental (ISR)
  9. Obsolet mentre es revalida: la línia temporal
  10. Revalidació sota demanda: revalidatePath i revalidateTag
  11. Triar bé el temps de revalidació
  12. Contingut des de fitxers locals o des d'un CMS
  13. Rutes estàtiques especials: sitemap, robots i les imatges de previsualització
  14. Imatges i tipografies: next/image i next/font
  15. Quan no convé l'estàtic
  16. Taula resum definitiva i arquitectura híbrida

  1. El malbaratament de renderitzar mil vegades el mateix

Posa xifres a l'aparador de CicloUrbano. Suposa 50.000 visites diàries a la pàgina de condicions del servei, i que renderitzar-la costa 40 ms de CPU al servidor.

Renderitzat dinàmic (SSR) Generació estàtica (SSG)
Renders al dia 50.000 0 (un a la construcció)
CPU consumida ~33 minuts diaris ~40 ms, una vegada
TTFB típic 80-250 ms 10-30 ms des del CDN
Sobreviu si l'API cau? No Sí
Sobreviu a un pic de trànsit? Només escalant Sí, el CDN ho absorbeix
Cost d'allotjament Servidor Node sempre encès Fitxers en un CDN

Totes les files apunten en la mateixa direcció i el motiu és un de sol: el resultat no depèn de la petició. Quan l'HTML és idèntic per a tothom i no canvia entre visites, generar-lo cada vegada és treball repetit.

Hi ha una diferència addicional que no apareix a la taula i que sol decidir l'arquitectura: la resiliència. Una pàgina estàtica ja està escrita al disc d'un CDN. Si json-server deixa de respondre, la fitxa de bici-002 es continua servint amb les últimes dades conegudes. Amb SSR, aquesta mateixa fitxa retorna un 500. Per a l'aparador públic d'una empresa, que la web es mantingui en peu quan el sistema intern falla no és un luxe: és el que s'espera.

  1. Què és exactament la generació estàtica

La generació estàtica consisteix a executar els components durant next build, desar l'HTML resultant al disc i servir aquest fitxer a tothom.

flowchart LR
    subgraph CONSTRUCCIO["next build · una vegada"]
        A["Components de servidor"] --> B["fetch a l'API"]
        B --> C["React renderitza"]
        C --> D["HTML + payload RSC<br/>guardats al disc"]
    end
    D --> E["CDN"]
    E --> F["Visitant 1"]
    E --> G["Visitant 2"]
    E --> H["Visitant 50.000"]

Tres precisions que eviten malentesos habituals:

  • Estàtic no significa «sense React». L'HTML prerenderitzat s'hidrata igual que el del SSR, i les illes de client (SelectorTipus, BotoTema) continuen sent interactives. L'única cosa que canvia és quan es va generar aquest HTML.
  • Estàtic no significa «sense dades». Els fetch s'executen; simplement s'executen a la màquina de construcció, no a cada petició.
  • Estàtic no significa «per sempre». Amb ISR, aquest HTML caduca i es regenera. És el que veurem a partir de l'apartat 8.

El codi d'una pàgina estàtica és idèntic al d'una dinàmica. No hi ha una API diferent: l'única cosa que canvia és si fas servir alguna cosa que obligui a esperar la petició.

// src/app/condiciones/page.jsx — pàgina estàtica sense fer res especial
export const metadata = {
  title: 'Condicions del servei',
  description: 'Condicions de lloguer de bicicletes de CicloUrbano.',
};

export default function PaginaCondicions() {
  return (
    <article>
      <h1>Condicions del servei</h1>
      <p>El lloguer es factura per hores completes des del moment del desbloqueig.</p>
      <h2>Fiances</h2>
      <p>Les bicicletes de càrrega requereixen una fiança de 30 € reemborsable.</p>
    </article>
  );
}

Aquesta pàgina no llegeix cookies, no llegeix searchParams i no fa cap fetch sense memòria cau. Next.js la prerenderitza sense que li ho demanis.

  1. Inventari de CicloUrbano: què guanya sent estàtic i què no

El criteri es pot reduir a dues preguntes: el resultat depèn de qui demana la pàgina? i amb quina freqüència canvia?

Pantalla Depèn de l'usuari? Freqüència de canvi Estratègia Per què
/condiciones, /sobre-nosotros, /tarifas No Dues vegades l'any SSG pur Text fix; regenerar-lo a cada visita no aporta res
/estaciones (llistat) No Alta o baixa segons què es mostri ISR 5 min Nom i barri són fixos; les places lliures canvien
/bicicletas/[bicicletaId] (fitxa) No Diverses vegades al dia ISR 60 s Centenars de pàgines, canvis moderats, molt valor de SEO
/ (catàleg amb disponibilitat en viu) No, però ha de ser exacte Constant SSR Mostrar disponibilitat obsoleta enganya l'usuari
/reservas Sí Constant CSR (SPA) Dades privades; res a prerenderitzar
/taller Sí, a més per rol Constant CSR (SPA) Darrere de RutaProtegida; cap valor de SEO

Observa el matís de la fitxa de bicicleta enfront del catàleg. Tots dos mostren l'estat de la bicicleta, però el compromís és diferent: el catàleg promet disponibilitat ara mateix i per això no es pot permetre dades d'un minut abans; la fitxa és sobretot una pàgina de contingut —model, tipus, preu, estació—, i un desfasament de 60 segons a l'etiqueta d'estat és acceptable si a canvi se serveix des del CDN. La decisió no és tècnica, és de producte: quanta obsolescència tolera cada pantalla.

  1. Com decideix Next.js entre estàtic i dinàmic

La regla de l'App Router s'enuncia en una frase:

Tota ruta és estàtica per defecte. Es torna dinàmica en el moment que fa servir alguna cosa que només es coneix en el moment de la petició.

Aquests «alguna cosa» són exactament els detonants que ja vas veure a 10-01, ara en el seu paper complet:

Detonant Efecte Com evitar-ho si vols estàtic
fetch(..., { cache: 'no-store' }) Dinàmica cache: 'force-cache' o next: { revalidate: N }
Un fetch sense opcions (Next.js 15) Dinàmica Igual que l'anterior: la memòria cau cal demanar-la
await cookies() Dinàmica Aïllar en un component de client o dins de <Suspense>
await headers(), connection() Dinàmica Igual
La prop searchParams d'una page Dinàmica Llegir el paràmetre en un component de client amb useSearchParams
export const dynamic = 'force-dynamic' Dinàmica Treure'l
export const revalidate = 0 Dinàmica Posar un valor més gran que zero

I els dos interruptors explícits que convé conèixer:

// Força el comportament de tota la ruta. Valors possibles:
export const dynamic = 'auto';           // per defecte: Next.js decideix
export const dynamic = 'force-static';   // força estàtic (cookies() retorna buit)
export const dynamic = 'force-dynamic';  // força render a cada petició
export const dynamic = 'error';          // estàtic, i ERROR si alguna cosa el fa dinàmic

dynamic = 'error' és una eina excel·lent en un projecte real: converteix «creia que aquesta pàgina era estàtica» en una fallada de construcció. És la mateixa filosofia de l'anàlisi estàtica del mòdul 9 aplicada al renderitzat.

Un avís important sobre searchParams: fer-lo servir en una page la fa dinàmica sencera, perquè el servidor necessita la URL completa. Si només vols reaccionar a ?tipo= sense perdre el prerenderitzat, la solució és llegir el paràmetre en un component de client i filtrar allà, o posar cada filtre en la seva pròpia ruta (/catalogo/electricas).

  1. Llegir la sortida de next build: ○, ● i ƒ

No cal suposar res: next build diu exactament què ha fet amb cada ruta.

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

Com es llegeix aquesta taula:

Símbol Significat Quan es genera l'HTML
○ Static Ruta sense paràmetres, prerenderitzada A next build
● SSG Ruta dinàmica prerenderitzada amb generateStaticParams A next build, una pàgina per paràmetre
ƒ Dynamic Renderitzada al servidor a cada petició A cada visita

I les columnes:

  • Size: el JavaScript propi d'aquesta ruta.
  • First Load JS: el total que descarrega qui entra per allà, inclòs el codi compartit. És la xifra del mòdul 8 i continua important.
  • Revalidate: el temps de caducitat, si n'hi ha.

Les línies indentades sota ● són les pàgines concretes generades. Si esperaves una ○ i veus una ƒ, alguna cosa ha activat un detonant: revisa cookies(), searchParams i les opcions de fetch. Aquesta comanda hauria de formar part de la teva rutina abans de desplegar, igual que npm test.

  1. generateStaticParams: prerenderitzar rutes dinàmiques

Una ruta com /bicicletas/[bicicletaId] té un problema evident: en la construcció, Next.js no sap quins identificadors existeixen. Pot prerenderitzar /condiciones perquè és una sola pàgina, però no pot endevinar que hi ha una bici-001 i una bici-002.

generateStaticParams és la funció que li ho diu. S'executa durant la construcció, retorna la llista de valors possibles del segment dinàmic, i Next.js genera una pàgina per cadascun.

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

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

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

  // Cada objecte de l'array correspon a un [bicicletaId] del nom de carpeta.
  return bicicletes.map((bicicleta) => ({ bicicletaId: bicicleta.id }));
}

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

export async function generateMetadata({ params }) {
  const { bicicletaId } = await params;
  const bicicleta = await obtenirBicicleta(bicicletaId);
  if (!bicicleta) return { title: 'Bicicleta no trobada' };
  return {
    title: `${bicicleta.model} · ${bicicleta.preuHora.toFixed(2)} €/h`,
    description: `Bicicleta ${bicicleta.tipus} disponible per llogar per hores.`,
  };
}

export default async function PaginaFitxaBicicleta({ params }) {
  const { bicicletaId } = await params;
  const bicicleta = await obtenirBicicleta(bicicletaId);
  if (!bicicleta) notFound();

  return (
    <article>
      <h1>{bicicleta.model}</h1>
      <EtiquetaEstat estat={bicicleta.estat} />
      <p>{bicicleta.preuHora.toFixed(2)} €/h · estació {bicicleta.estacioId}</p>
    </article>
  );
}

Anàlisi línia a línia del que ha canviat respecte a 10-01:

  • La forma del valor retornat importa. Cada objecte ha de tenir una clau amb el nom exacte del segment: la carpeta és [bicicletaId], així que la clau és bicicletaId. I el valor és sempre una cadena, encara que l'identificador fos numèric.
  • cache: 'no-store' ha desaparegut i en el seu lloc hi ha next: { revalidate: 60 }. Això és el que permet que la ruta sigui estàtica (● al build) i alhora es refresqui cada minut.
  • L'etiqueta tags: ['bicicleta-<id>'] no fa res per si sola; és l'ancoratge per a la revalidació sota demanda de l'apartat 10.
  • generateStaticParams i generateMetadata i el component comparteixen els fetch gràcies a la deduplicació automàtica que ja has vist.

En rutes imbricades, cada nivell dinàmic aporta la seva funció. Per a /estaciones/[estacionId]/incidencias, el generateStaticParams de [estacionId] s'hereta i no cal repetir-lo:

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

Amb les tres estacions del cànon, la construcció genera est-01, est-02 i est-03, cadascuna amb la seva pestanya índex i la seva pestanya d'incidències: sis pàgines.

  1. dynamicParams: què passa amb un identificador nou

Pregunta inevitable: l'equip de CicloUrbano dona d'alta bici-006 després del desplegament. Aquesta pàgina no existeix al disc. Què passa quan algú la visita?

Ho decideix dynamicParams:

// Per defecte: true
export const dynamicParams = true;
Valor Comportament davant un paràmetre no generat
true (per defecte) Es renderitza al servidor la primera vegada, es desa en memòria cau i les visites següents es serveixen des d'ella
false Retorna un 404 directament, sense cridar el component

Quan fer servir cadascun:

  • true per a catàlegs que creixen: bicicletes, estacions, articles. És el comportament desitjat a CicloUrbano: la bicicleta nova funciona a l'instant, només que la primera visita paga el render.
  • false quan el conjunt és tancat i conegut: idiomes suportats, categories fixes, pàgines legals. Així una URL inventada no dispara un render inútil, cosa que a més tanca una via d'abús.

Amb dynamicParams = true, generateStaticParams deixa de ser una llista exhaustiva i passa a ser una llista d'escalfament. Un patró molt útil en catàlegs grans és prerenderitzar només el més visitat i deixar que la resta es generi sota demanda:

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

  // Només les disponibles: són les que la gent consulta i comparteix.
  return bicicletes
    .filter((bicicleta) => bicicleta.estat === 'disponible')
    .map((bicicleta) => ({ bicicletaId: bicicleta.id }));
}

Amb el cànon de CicloUrbano, això genera bici-001, bici-004 i bici-005 a la construcció; bici-002 (llogada) i bici-003 (en manteniment) es generaran la primera vegada que algú les demani.

  1. Revalidació incremental (ISR)

Aquí hi ha la idea que fa utilitzable la generació estàtica en un lloc amb dades reals, i és la quarta columna de la taula del mòdul.

ISR (Incremental Static Regeneration) serveix la pàgina estàtica desada, i quan aquesta pàgina porta més de N segons sense regenerar-se, la torna a generar en segon pla. El visitant que arriba just després de la caducitat no espera: rep la versió antiga, i la nova queda llesta per al següent.

Es declara de dues formes, que es poden combinar:

// A) Per ruta: afecta tota la pàgina.
export const revalidate = 300; // segons

// B) Per petició: cada fetch té el seu propi cicle de vida.
const resposta = await fetch(`${API}/bicicletas/${id}`, {
  next: { revalidate: 60 },
});

Diferències i quin fer servir:

export const revalidate = N next: { revalidate: N }
Abast Tota la ruta Una petició concreta
Granularitat Baixa Alta
Quan fer-lo servir La pàgina té un ritme únic Dades amb ritmes diferents a la mateixa pàgina

Quan conviuen, guanya el més curt: si la ruta declara 300 i un fetch declara 60, la pàgina es regenera cada 60 segons.

Exemple real amb dos ritmes a la mateixa pàgina, el llistat d'estacions:

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

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

// Sostre de la pàgina: com a molt, 10 minuts d'obsolescència.
export const revalidate = 600;

export default async function PaginaEstacions() {
  const [estacions, ocupacio] = await Promise.all([
    // El nom i el barri d'una estació no canvien mai: una hora està bé.
    fetch(`${API}/estaciones`, { next: { revalidate: 3600 } }).then((r) => r.json()),
    // Les places lliures canvien constantment: un minut.
    fetch(`${API}/ocupacion`, { next: { revalidate: 60 } }).then((r) => r.json()),
  ]);

  return (
    <section>
      <h1>Les nostres estacions</h1>
      <ul>
        {estacions.map((estacio) => (
          <li key={estacio.id}>
            <TargetaEstacio
              estacio={estacio}
              placesLliures={ocupacio[estacio.id] ?? 0}
            />
          </li>
        ))}
      </ul>
    </section>
  );
}

En el build, aquesta ruta apareix com ● amb Revalidate: 1m —el mínim efectiu—, i la pàgina se serveix des del CDN excepte la regeneració ocasional.

  1. Obsolet mentre es revalida: la línia temporal

Aquest és el mecanisme exacte, i val la pena entendre'l bé perquè explica una confusió molt habitual: «he canviat la dada, he recarregat i continuo veient la vella». No és una fallada; és el disseny.

Amb revalidate = 60 i una regeneració que triga 2 segons:

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

    V1->>N: GET /bicicletas/bici-002
    N-->>V1: HTML cachejat (fresc) · rapid
    Note over N: Cache generada en t=0. Caduca en t=60.

    V2->>N: GET /bicicletas/bici-002
    N-->>V2: HTML cachejat (OBSOLET) · rapid
    Note over N: Caducada: es llanca la regeneracio EN SEGON PLA
    N->>API: GET /bicicletas/bici-002
    API-->>N: JSON actualitzat
    Note over N: Nova cache llesta en t=77

    V3->>N: GET /bicicletas/bici-002
    N-->>V3: HTML NOU · rapid

El que cal retenir:

  • Ningú espera mai. Ni tan sols el visitant B, que és qui dispara la regeneració. Aquest patró s'anomena stale-while-revalidate.
  • El visitant B veu contingut obsolet, i és una decisió conscient: es prefereix una resposta ràpida i lleugerament antiga a una resposta lenta i exacta.
  • revalidate: 60 no significa «es regenera cada 60 segons». Significa «passats 60 segons, la pròxima visita dispara la regeneració». Si ningú entra en tres dies, no es regenera res. La memòria cau és mandrosa, no un cronòmetre.
  • La regeneració és única. Si arriben cent visitants alhora després de la caducitat, es dispara una sola regeneració; els cent reben la versió antiga.

D'aquí surt una conseqüència pràctica: en desenvolupament (npm run dev) no veuràs aquest comportament, perquè el mode de desenvolupament renderitza sempre. Per comprovar l'ISR de veritat cal executar npm run build && npm run start. És la mateixa disciplina que al mòdul 8 amb el Profiler: es mesura sobre el build de producció.

  1. Revalidació sota demanda: revalidatePath i revalidateTag

L'ISR per temps resol la majoria dels casos, però deixa un buit: quan l'equip de CicloUrbano dona d'alta bici-006 o baixa el preu de les elèctriques, no vol esperar 60 segons ni 10 minuts. Vol que el canvi es reflecteixi ja.

La revalidació sota demanda inverteix el control: en lloc que la memòria cau caduqui sola, algú la invalida explícitament.

Hi ha dues funcions, importades de next/cache:

Funció Què invalida Quan fer-la servir
revalidatePath(ruta) L'HTML en memòria cau d'una ruta concreta Saps exactament quina pàgina canvia
revalidateTag(etiqueta) Totes les peticions marcades amb aquesta etiqueta, estiguin on estiguin La dada apareix en diverses pàgines

Les etiquetes es declaren al propi fetch, i aquí hi ha la potència del mecanisme:

// src/consultes/aparador.js
const API = 'http://localhost:3001';

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

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

Fixa't que obtenirBicicleta porta dues etiquetes: una general i una específica. Això permet invalidar amb dues granularitats diferents: revalidateTag('bicicleta-bici-002') afecta només aquesta fitxa; revalidateTag('bicicletas') afecta la fitxa, el llistat i qualsevol altra pàgina que faci servir aquestes dades, sense que les hagis d'enumerar.

El disparador és un gestor de ruta (route.js), que a l'App Router és l'equivalent a un endpoint d'API. El panell intern de CicloUrbano el cridarà en desar una bicicleta:

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

export async function POST(peticio) {
  // 1. Autenticació: sense això, qualsevol pot tirar la teva memòria cau.
  const secret = peticio.headers.get('x-secret-revalidacio');
  if (secret !== process.env.SECRET_REVALIDACIO) {
    return NextResponse.json({ error: 'No autoritzat' }, { status: 401 });
  }

  // 2. Què cal invalidar.
  const { etiqueta, ruta } = await peticio.json();

  if (etiqueta) revalidateTag(etiqueta);
  if (ruta) revalidatePath(ruta);

  return NextResponse.json({ revalidat: true, moment: Date.now() });
}

I així s'invoca des del sistema de gestió quan algú edita bici-002:

curl -X POST http://localhost:3000/api/revalidar \
  -H "Content-Type: application/json" \
  -H "x-secret-revalidacio: $SECRET_REVALIDACIO" \
  -d '{"etiqueta":"bicicleta-bici-002"}'

Tres advertiments importants:

  • L'autenticació no és opcional. Un endpoint de revalidació obert permet a qualsevol forçar renders en bucle: és una denegació de servei servida en safata.
  • revalidatePath amb una ruta dinàmica requereix el segon argument: revalidatePath('/bicicletas/[bicicletaId]', 'page') invalida totes les fitxes.
  • La invalidació no regenera immediatament: marca la memòria cau com a caducada. La regeneració la dispara la següent visita, com a l'apartat 9.

La mateixa funció revalidateTag es pot cridar des d'una acció de servidor, que és la forma natural de fer-ho quan el propi Next.js gestiona el formulari. Les accions de servidor són material de 10-03.

  1. Triar bé el temps de revalidació

El valor de revalidate no es tria a l'atzar. La pregunta correcta és: quants segons de desfasament entre la realitat i el que veu l'usuari són acceptables aquí?

Contingut de CicloUrbano Valor raonable Raonament
Condicions del servei, «sobre nosaltres» false (mai caduca) Canvia amb un desplegament; revalidar sota demanda si cal
Tarifes 86400 (1 dia) Un canvi de preu es comunica; un dia de desfasament és tolerable
Llistat d'estacions (dades fixes) 3600 (1 hora) Nom, barri i places totals gairebé no canvien
Fitxa de bicicleta 60 (1 minut) Bon equilibri entre frescor i cost, amb etiqueta per forçar
Places lliures per estació 60 (1 minut) Orientatiu per definició; ningú espera exactitud al segon
Catàleg amb disponibilitat en viu 0 / SSR Aquí l'obsolescència sí que enganya l'usuari
Panell de taller No aplica Dades privades; va a la SPA

Dues heurístiques útils:

  • Comença generós i baixa si cal. Un valor alt amb revalidació sota demanda com a sortida d'emergència sol ser millor que un valor baix: costa menys i dona control exacte.
  • Si el número correcte sembla ser 0, la ruta no és estàtica. Força-la a dinàmica i sigues explícit; no simulis SSR amb revalidate: 1, perquè pagues la complexitat de les dues estratègies sense l'avantatge de cap.

  1. Contingut des de fitxers locals o des d'un CMS

Un component de servidor no està limitat a fetch. Com que s'executa a Node, pot llegir el sistema de fitxers, una cosa impensable a la SPA. És la forma més directa de gestionar les pàgines informatives de CicloUrbano en Markdown.

npm install gray-matter remark remark-html
contingut/
├── condiciones.md
├── sobre-nosotros.md
└── tarifas.md
---
titol: Condicions del servei
descripcio: Condicions de lloguer de bicicletes de CicloUrbano.
actualitzat: 2026-03-14
---

## Facturació

El lloguer es factura per hores completes des del desbloqueig.
// src/app/[pagina]/page.jsx — una ruta per a totes les informatives
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 DIRECTORI = path.join(process.cwd(), 'contingut');

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

// Conjunt tancat: qualsevol altra URL ha de ser un 404.
export const dynamicParams = false;

async function llegirPagina(nom) {
  try {
    const brut = await fs.readFile(path.join(DIRECTORI, `${nom}.md`), 'utf8');
    const { data, content } = matter(brut);
    const processat = await remark().use(html).process(content);
    return { meta: data, html: processat.toString() };
  } catch {
    return null;
  }
}

export async function generateMetadata({ params }) {
  const { pagina } = await params;
  const contingut = await llegirPagina(pagina);
  if (!contingut) return { title: 'Pàgina no trobada' };
  return { title: contingut.meta.titol, description: contingut.meta.descripcio };
}

export default async function PaginaInformativa({ params }) {
  const { pagina } = await params;
  const contingut = await llegirPagina(pagina);
  if (!contingut) notFound();

  return (
    <article>
      <h1>{contingut.meta.titol}</h1>
      <p><small>Actualitzat el {contingut.meta.actualitzat}</small></p>
      <div dangerouslySetInnerHTML={{ __html: contingut.html }} />
    </article>
  );
}

Punts a destacar:

  • fs en un component. Només funciona perquè és de servidor. Si hi afegissis 'use client' a aquest fitxer, la construcció fallaria: no hi ha sistema de fitxers al navegador.
  • dynamicParams = false és el correcte aquí: el conjunt de pàgines informatives és tancat i una URL inventada ha de donar 404.
  • dangerouslySetInnerHTML és acceptable perquè el Markdown l'escriu l'equip, no un usuari anònim. Amb contingut de tercers caldria sanejar-lo abans.
  • Substituir els fitxers per un CMS (Contentful, Strapi, Sanity, WordPress headless) no canvia l'estructura: només canvia llegirPagina per un fetch a la seva API, i la revalidació sota demanda es dispara des del webhook del CMS al route.js de l'apartat 10.

  1. Rutes estàtiques especials: sitemap, robots i les imatges de previsualització

Next.js reserva alguns noms de fitxer que generen artefactes estàtics a la construcció. Són els que completen la feina de SEO començada a 10-01.

El mapa del lloc. Un fitxer que exporta una funció per defecte i produeix sitemap.xml:

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

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

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

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

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

  return [...fixes, ...fitxes, ...parades];
}

Les regles per als robots. Aquí es fa explícit el repartiment de l'arquitectura:

// src/app/robots.js
export default function robots() {
  return {
    rules: [
      {
        userAgent: '*',
        allow: '/',
        // El que viu a la SPA o és privat no s'ha de rastrejar.
        disallow: ['/api/', '/taller', '/reservas'],
      },
    ],
    sitemap: 'https://ciclourbano.test/sitemap.xml',
  };
}

Les imatges de previsualització oberta, generades a la construcció. A 10-01 apuntàvem images: [{ url: '/imatges/bici-002.jpg' }] a un fitxer que algú havia de crear a mà. Next.js pot dibuixar-la amb 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 = 'Bicicleta de CicloUrbano';

export default async function ImatgePrevisualitzacio({ params }) {
  const { bicicletaId } = await params;
  const bicicleta = 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 }}>{bicicleta.model}</div>
        <div style={{ fontSize: 40 }}>
          {bicicleta.preuHora.toFixed(2)} €/h · {bicicleta.tipus}
        </div>
      </div>
    ),
    size,
  );
}

Notes: el JSX de ImageResponse no és React del navegador —és una descripció que es converteix en PNG—, i per això només admet un subconjunt de CSS basat en Flexbox, amb display: 'flex' obligatori als contenidors. Com que conviu amb generateStaticParams, les imatges es generen a la construcció i se serveixen des del CDN, amb la qual cosa la targeta de WhatsApp de cada bicicleta és diferent sense feina de disseny per fitxa.

  1. Imatges i tipografies: next/image i next/font

Dues optimitzacions que l'aparador aprofita directament i que no existeixen al projecte Vite sense instal·lar res.

next/image substitueix <img> i resol d'una vegada diversos problemes de rendiment:

import Image from 'next/image';

<Image
  src={`/imatges/${bicicleta.id}.jpg`}
  alt={`Bicicleta ${bicicleta.model} de CicloUrbano`}
  width={640}
  height={480}
  priority={esLaPrincipal}
  sizes="(max-width: 768px) 100vw, 640px"
/>
Què fa Per què importa
Converteix a WebP/AVIF i redimensiona sota demanda Redueix el pes de la imatge de forma dràstica
Genera srcset a partir de sizes El mòbil no descarrega la versió d'escriptori
Càrrega mandrosa per defecte Les targetes de sota no competeixen amb les de dalt
Reserva l'espai amb width/height Elimina el salt de contingut (CLS)
priority desactiva la càrrega mandrosa i precarrega Millora el LCP a la imatge principal

alt és obligatori; l'accessibilitat del mòdul 3 continua aplicant igual.

next/font autoallotja les tipografies a la construcció, sense peticions a servidors externs:

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

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

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

Això descarrega la tipografia a next build, la serveix des del teu propi domini (millor privacitat i una connexió menys), i genera els descriptors necessaris perquè el canvi de tipografia no mogui el text, que és una altra font clàssica de CLS.

  1. Quan no convé l'estàtic

L'estàtic és tan còmode que és fàcil passar-se. Aquests són els límits reals:

Situació Problema Què fer
200.000 pàgines que canvien diàriament La construcció triga hores i es repeteix a cada desplegament generateStaticParams amb les més visitades + dynamicParams: true
Contingut personalitzat per usuari Un HTML per usuari no té sentit SSR amb cookies(), o CSR dins d'una illa de client
Dades que han de ser exactes al segon L'ISR serveix per definició contingut obsolet SSR sense memòria cau
Cerca amb paràmetres lliures Infinites combinacions de searchParams Ruta dinàmica, o filtrar al client
Preus, existències, saldos Mostrar una dada caducada té conseqüències legals o econòmiques SSR, o marcar la part crítica com a dinàmica
Contingut després d'autenticació No es pot prerenderitzar el que depèn d'una sessió SPA o SSR

Hi ha un patró que resol la majoria d'aquests casos sense renunciar a l'estàtic: una pàgina estàtica amb un forat dinàmic. La fitxa de bici-002 es prerenderitza sencera —model, preu, descripció, imatge— i només el bloc de disponibilitat en viu s'omple a part, embolcallat en <Suspense>. S'obté el TTFB del CDN i l'exactitud del SSR a la mateixa pàgina. Aquest patró és el cor de 10-03, i és la raó que la pròxima lliçó sigui sobre el model i no sobre més funcions de Next.js.

  1. Taula resum definitiva i arquitectura híbrida

Tancament del mapa. Aquesta és la taula de les quatre estratègies aplicada pantalla per pantalla a CicloUrbano, amb la implementació concreta:

Pantalla Estratègia Projecte Implementació
/ catàleg amb disponibilitat 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, dos ritmes
/estaciones/[estacionId] (+pestanyes) ISR 5 min ciclourbano-web generateStaticParams al layout
/condiciones, /tarifas, /sobre-nosotros SSG ciclourbano-web Markdown local + dynamicParams: false
sitemap.xml, robots.txt, imatges OG SSG ciclourbano-web sitemap.js, robots.js, opengraph-image.jsx
/acceso CSR SPA Vite PaginaAcces + sliceSessio
/reservas, /reservas/nueva CSR SPA Vite TanStack Query + sliceReserves
/taller CSR SPA Vite RutaProtegida + RequereixRol

I així queda l'arquitectura completa:

flowchart TB
    subgraph PUBLIC["Aparador public · ciclourbano-web (Next.js 15)"]
        CDN["CDN"]
        SSG["SSG: condicions, tarifes, sitemap, robots"]
        ISR["ISR: fitxes de bicicleta, estacions"]
        SSR["SSR: cataleg amb disponibilitat"]
    end

    subgraph GESTIO["Aplicacio de gestio · SPA (Vite + React Router)"]
        SPA["CSR: acces, reserves, taller"]
        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["Visitant anonim<br/>i cercadors"]
    SSR --> U1
    SPA --> U2["Usuari identificat<br/>Ana Ribera / Marc Sole"]

    U1 -. "Iniciar sessio" .-> SPA

Aquesta arquitectura és habitual en producció i mereix anomenar-se: l'aparador i l'aplicació són dos projectes, dos desplegaments i dos models de renderitzat, units per una API comuna i per una cookie de sessió compartida. No és una transició a mitges cap a Next.js: és la decisió correcta, perquè les dues parts tenen requisits oposats. El que és públic necessita ser ràpid, indexable i resistent; el que és privat necessita ser interactiu i estar sempre al dia.

Sobre el projecte final. Convé dir-ho aquí amb claredat perquè no hi hagi confusió: el Mòdul 11 construeix l'aplicació completa amb Vite + React Router, la que portes nou mòduls muntant. Next.js és una eina que ara ja saps fer servir i situar, no un canvi de rumb del curs. Quan en un projecte real hagis de decidir, tindràs el criteri de la taula de dalt.

Errors Comuns i Consells

  • Provar l'ISR amb npm run dev. En desenvolupament, Next.js renderitza sempre i no guarda res en memòria cau. L'ISR només es comprova amb npm run build && npm run start.
  • Esperar que revalidate: 60 es regeneri sola cada minut. No hi ha temporitzador: la regeneració la dispara una visita després de la caducitat. Sense trànsit, no hi ha regeneració.
  • Afegir console.log per depurar i trobar-lo buit. Si la pàgina és estàtica, aquest console.log es va executar un cop, durant next build, i la seva sortida és al registre de la construcció, no al del servidor.
  • Retornar claus amb el nom equivocat a generateStaticParams. Si la carpeta és [bicicletaId], la clau ha de ser bicicletaId. I el valor, una cadena.
  • Fer servir searchParams sense adonar-te que trenca el prerenderitzat. Una sola lectura converteix la pàgina en ƒ. Si necessites paràmetres de consulta sense perdre l'estàtic, llegeix-los al client amb useSearchParams.
  • Deixar /api/revalidar sense autenticació. És un vector de denegació de servei: qualsevol pot forçar renders en bucle. Secret a la capçalera, sempre.
  • Fixar revalidate: 1 «per si de cas». És SSR amb complexitat extra. Si necessites frescor absoluta, declara la ruta dinàmica i sigues explícit.
  • Oblidar width i height a next/image. Sense ells no es reserva espai i torna el salt de contingut que el component venia a evitar.
  • Consell: fes de next build part de la teva integració contínua. Combinat amb export const dynamic = 'error' a les rutes que han de ser estàtiques, un fetch mal configurat deixa de ser un problema de producció i passa a ser una fallada de construcció.
  • Consell: prerenderitza només el que es visita. En catàlegs grans, generateStaticParams amb el més popular i dynamicParams: true dona el 95 % del benefici amb una fracció del temps de construcció.

Exercicis

Exercici 1. Aquest next build no és l'esperat. Les tres primeres rutes haurien de ser estàtiques o SSG i apareixen com a dinàmiques. Per a cadascuna, proposa la causa més probable i la correcció.

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

Saps a més que: /condiciones només conté text en JSX; /bicicletas/[bicicletaId] té generateStaticParams; /estaciones fa dos fetch sense opcions; i / llegeix searchParams. Indica també quina de les quatre ha de continuar sent ƒ.

Exercici 2. Dissenya l'estratègia de memòria cau de /estaciones/[estacionId], que mostra: nom, barri i places totals (fixos); la flota aparcada ara mateix (canvia cada pocs minuts); i les incidències obertes (s'han de veure tan bon punt un operari en dona d'alta una). Escriu el codi de les peticions amb els seus revalidate i les seves tags, i explica com es força l'actualització de les incidències sense esperar.

Exercici 3. L'equip de CicloUrbano vol una secció de blog a l'aparador: articles en Markdown a contingut/blog/, llistat a /blog i detall a /blog/[slug]. Els articles es publiquen un cop o dos al mes. Decideix l'estratègia de renderitzat, el valor de dynamicParams i què cal afegir al sitemap.js, i justifica cada decisió en una frase.

Solucions

Solució 1.

Ruta Causa probable Correcció
/condiciones Alguna cosa global la contamina: gairebé segur un await cookies() a layout.jsx (el MenuUsuari de 10-01), o un export const dynamic = 'force-dynamic' heretat Treure la lectura de la cookie del layout de servidor: llegir-la en un component de client o aïllar-la en un component embolcallat en <Suspense>
/bicicletas/[bicicletaId] Té generateStaticParams, però els fetch van sense memòria cau: a Next.js 15 això n'hi ha prou per fer-la dinàmica Canviar a next: { revalidate: 60, tags: [...] }
/estaciones Mateix motiu: fetch sense opcions ja no es cacheja a Next.js 15 Afegir next: { revalidate: N } a cada petició
/ Llegeix searchParams i mostra disponibilitat en viu Ha de continuar sent ƒ. És el cas legítim de SSR de tot l'aparador

L'aprenentatge general: una ruta que sembla estàtica i surt com a ƒ gairebé sempre té la causa en un ancestre o en una opció de fetch oblidada.

Solució 2.

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

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

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

  const [estacio, flota, incidencies] = await Promise.all([
    // 1. Dades fixes: una hora és més que suficient.
    fetch(`${API}/estaciones/${estacionId}`, {
      next: { revalidate: 3600, tags: [`estacio-${estacionId}`] },
    }).then((r) => r.json()),

    // 2. Flota aparcada: canvia cada pocs minuts.
    fetch(`${API}/bicicletas?estacioId=${estacionId}`, {
      next: { revalidate: 120, tags: ['bicicletas', `flota-${estacionId}`] },
    }).then((r) => r.json()),

    // 3. Incidències: caducitat llarga perquè s'invaliden sota demanda.
    fetch(`${API}/incidencias?estacioId=${estacionId}`, {
      next: { revalidate: 3600, tags: [`incidencies-${estacionId}`] },
    }).then((r) => r.json()),
  ]);

  return (
    <>
      <p>{estacio.barri} · {estacio.places} places</p>
      <p>{flota.length} bicicletes aparcades</p>
      <p>{incidencies.length} incidències obertes</p>
    </>
  );
}

La clau està en la tercera petició: no s'usa un revalidate curt, sinó un de llarg amb una etiqueta. Quan un operari dona d'alta una incidència des del panell de taller, el sistema de gestió crida l'endpoint de revalidació:

curl -X POST http://localhost:3000/api/revalidar \
  -H "x-secret-revalidacio: $SECRET_REVALIDACIO" \
  -H "Content-Type: application/json" \
  -d '{"etiqueta":"incidencies-est-02"}'

Així s'obté el millor de les dues estratègies: cost mínim en règim normal (una regeneració per hora com a molt) i actualització immediata quan de veritat hi ha alguna cosa nova. Baixar revalidate a 10 segons donaria un resultat pitjor i més car.

Solució 3.

  • Estratègia: SSG pur per al llistat i per al detall. Els articles són fitxers del repositori, així que qualsevol canvi implica un desplegament, i el desplegament ja reconstrueix el lloc: una revalidació per temps no aportaria res.
  • dynamicParams = false a /blog/[slug]: el conjunt d'articles està tancat en el moment de la construcció, i un slug inventat ha de retornar un 404 real en lloc de disparar un render.
  • generateStaticParams llegeix contingut/blog/ amb fs.readdir i retorna un { slug } per fitxer.
  • A sitemap.js cal afegir la ruta /blog i una entrada per article, amb lastModified pres del camp data del front matter —no de new Date(), que marcaria tots els articles com a acabats de modificar a cada construcció i degradaria el senyal per als cercadors— i changeFrequency: 'monthly'.
  • Extra recomanable: un opengraph-image.jsx a /blog/[slug] amb el títol de l'article, perquè compartir un enllaç en un xat mostri una targeta pròpia.

Si en el futur els articles es mouen a un CMS, l'estratègia passaria a ISR amb revalidació sota demanda des del webhook del CMS: la resta del codi no canviaria.

Conclusió

Amb aquesta lliçó queda complet el mapa de renderitzat que obria el mòdul. A 10-01 l'HTML es generava a cada petició; aquí es genera una vegada, a next build, se serveix des d'un CDN i —amb ISR— es refresca només quan cal.

L'essencial de l'estratègia. A l'App Router tota ruta és estàtica per defecte i es torna dinàmica en el moment que fa servir alguna cosa que només es coneix en la petició: cookies(), headers(), searchParams o un fetch sense memòria cau —que a Next.js 15 és el comportament per defecte, així que la memòria cau cal demanar-la—. I no se suposa res: next build ho diu, amb ○ per a estàtic, ● per a SSG amb generateStaticParams i ƒ per a dinàmic. Aquesta comanda pertany a la rutina prèvia al desplegament tant com npm test.

De les eines, en queden fixades quatre. generateStaticParams enumera els valors d'un segment dinàmic i genera una pàgina per cadascun, amb la clau anomenada exactament com la carpeta. dynamicParams decideix què passa amb el que no hi era: true ho renderitza sota demanda i ho desa en memòria cau —el correcte per a un catàleg que creix—, false retorna un 404 —el correcte per a un conjunt tancat—. revalidate, per ruta o per petició, implementa el patró stale-while-revalidate: ningú espera mai, algú veu contingut lleugerament antic, i la caducitat no és un temporitzador sinó una condició que dispara la següent visita. I revalidateTag / revalidatePath inverteixen el control per als casos en què esperar no compensa: etiquetes declarades al fetch, un route.js autenticat i una crida des del sistema de gestió. La combinació guanyadora, que has vist a l'exercici 2, és caducitat llarga més etiqueta: cost mínim en repòs i actualització immediata quan hi ha novetat.

L'aparador ha guanyat a més la seva capa de SEO i rendiment completa: sitemap.js amb les fitxes i les estacions, robots.js que exclou el que viu a la SPA, opengraph-image.jsx que dibuixa una targeta diferent per bicicleta a la construcció, next/image amb el seu srcset, la seva càrrega mandrosa i la seva reserva d'espai, i next/font autoallotjant la tipografia sense salts de text. I les pàgines informatives surten de fitxers Markdown llegits amb fs des d'un component de servidor, una cosa que a la SPA era senzillament impossible.

L'arquitectura de CicloUrbano queda tancada i és deliberadament híbrida: aparador públic a ciclourbano-web —catàleg en SSR, fitxes i estacions en ISR, informatives en SSG— i aplicació de gestió a la SPA de Vite —accés, reserves i taller en CSR amb Redux Toolkit i TanStack Query—, unides per l'API comuna i per una cookie de sessió compartida. I amb això, el recordatori que ja s'ha fet a dalt: el projecte final del Mòdul 11 es construeix amb Vite + React Router.

Queda una peça sense explicar, i és precisament la que sosté tot l'anterior. Has escrit 'use client' sense saber del tot què significa; has acceptat que un component de servidor «no s'envia al navegador» sense veure com; has posat un loading.jsx sabent que per sota hi ha un <Suspense>; i l'apartat 15 ha acabat prometent el patró que resol gairebé tots els casos difícils: una pàgina estàtica amb un forat dinàmic a dins. La pròxima lliçó deixa de banda la sintaxi de Next.js i va al model: què és exactament un límit de suspensió, com un component «se suspèn», com el servidor envia l'HTML per parts, on s'executa cada component i què pot i no pot travessar la frontera entre servidor i client. La pròxima lliçó és Suspense i React Server Components.

Curs de React

Mòdul 1: Introducció a React

Mòdul 2: Components de React

Mòdul 3: Treballar amb Esdeveniments

Mòdul 4: Conceptes Avançats de Components

Mòdul 5: Hooks de React

Mòdul 6: Enrutament a React

Mòdul 7: Gestió de l'Estat

Mòdul 8: Optimització del Rendiment

Mòdul 9: Proves a React

Mòdul 10: Temes Avançats

Mòdul 11: Projecte: Construir una Aplicació Completa

© Copyright 2026. Tots els drets reservats