El mòdul anterior va acabar assenyalant una frontera: CicloUrbano és una aplicació que es descarrega al navegador i s'hi executa. Aquesta decisió, que al mòdul 1 semblava purament tècnica, té conseqüències que no s'arreglen amb memo, ni amb divisió de codi, ni amb proves. El primer pintat espera el JavaScript; els cercadors i les xarxes socials veuen una pàgina buida; i en una connexió lenta l'usuari mira un fons blanc durant segons. Aquesta lliçó travessa aquesta frontera. Entendràs què fa exactament el navegador quan rep una aplicació d'una sola pàgina, quines són les quatre estratègies de renderitzat que existeixen i quan convé cadascuna, què és la hidratació i per què és el punt on el SSR es trenca, i com es construeix tot això amb Next.js 15 i el seu App Router. Al final tindràs en marxa ciclourbano-web, l'aparador públic de CicloUrbano renderitzat al servidor, convivint amb l'aplicació de gestió que portes nou mòduls construint.
Contingut
- El que rep avui el navegador: un
<div>buit - Què implica per al primer pintat i per a les connexions lentes
- El que veuen els cercadors i les targetes de previsualització
- Les quatre estratègies de renderitzat
- Què és la hidratació
- Discrepàncies entre servidor i client
- Per què cal un framework
- Next.js 15: crear
ciclourbano-web - Rutes per carpetes:
layout,page,loading,error,not-found - Del mapa de React Router al mapa de Next.js
- Components de servidor: dades amb
async/await - La directiva
'use client', en quatre línies - Renderitzat dinàmic: quan Next.js decideix renderitzar a cada petició
- Metadades i SEO
- Sessió sense
localStorage - Streaming: l'HTML per parts
- El que rep avui el navegador: un
<div> buit
<div> buitObre l'index.html del projecte Vite de CicloUrbano i mira què s'envia realment al navegador. No és el que veus en pantalla: és això.
<!doctype html>
<html lang="ca">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CicloUrbano</title>
<script type="module" crossorigin src="/assets/index-a3f9c2.js"></script>
<link rel="stylesheet" href="/assets/index-7b21e4.css" />
</head>
<body>
<div id="root"></div>
</body>
</html>Aquest és tot l'HTML de CicloUrbano. Un contenidor buit i una etiqueta <script>. Tota la resta —la capçalera, el catàleg, les cinc targetes de bicicleta, el peu de pàgina— la construeix JavaScript al navegador després.
Ho pots comprovar tu mateix sense eines especials. Al navegador, «Mostra el codi font de la pàgina» (Ctrl+U) mostra l'HTML tal com va arribar del servidor, abans que s'executi res. L'inspector d'elements, en canvi, mostra el DOM ja construït. La diferència entre aquestes dues vistes és exactament la diferència entre el que envia el servidor i el que fabrica React.
La seqüència real, pas a pas, quan algú obre la fitxa de bici-002:
sequenceDiagram
participant N as Navegador
participant S as Servidor estatic
participant A as API (json-server)
N->>S: GET /bicicletas/bici-002
S-->>N: HTML amb div#root buit
Note over N: Pantalla en blanc
N->>S: GET /assets/index.js (270 KB gzip)
S-->>N: JavaScript
Note over N: Analitzar + executar
Note over N: React munta · primer render
N->>A: GET /bicicletas/bici-002
A-->>N: JSON
Note over N: Segon render · contingut visible
Hi ha tres viatges d'anada i tornada encadenats abans que aparegui el model de la bicicleta: l'HTML, el JavaScript i les dades. I són seqüencials: el navegador no pot demanar les dades fins que no ha executat el JavaScript, i no pot executar el JavaScript fins que no ha rebut l'HTML que hi fa referència.
- Què implica per al primer pintat i per a les connexions lentes
Traduït a les mètriques que es fan servir per mesurar l'experiència real:
| Mètrica | Què mesura | En una SPA pura |
|---|---|---|
| TTFB (Time To First Byte) | Quant triga el primer byte de l'HTML | Molt bo: l'HTML és minúscul i estàtic |
| FCP (First Contentful Paint) | Quan apareix el primer contingut | Dolent: espera a descarregar i executar el JS |
| LCP (Largest Contentful Paint) | Quan apareix l'element principal | Molt dolent: espera a més les dades de l'API |
| TTI (Time To Interactive) | Quan respon a un clic | Coincideix més o menys amb el LCP |
El TTFB enganya: és excel·lent, però el que arriba en aquest primer byte no serveix de res. El que l'usuari percep és el FCP i el LCP, i tots dos són al final d'una cadena d'esperes.
Ara afegeix la variable que s'oblida quan es desenvolupa en un portàtil amb fibra: no tothom té la teva connexió ni el teu mòbil. Al mòdul 8 ja vam veure que analitzar i executar JavaScript en un mòbil de gamma mitjana va entre 3 i 5 vegades més lent. Sobre una xarxa 4G amb congestió, els 270 KB comprimits triguen més d'un segon només a arribar.
I hi ha un cas pitjor que el lent: el que falla. Si el fitxer de JavaScript no es descarrega —xarxa inestable, un bloquejador massa agressiu, un error de desplegament al CDN—, l'usuari no veu una versió degradada de CicloUrbano. Veu una pàgina en blanc. Amb HTML renderitzat al servidor, aquest mateix usuari veu el contingut; el que perd és la interactivitat, que és una degradació molt més raonable.
Idea clau. Una SPA fa que el contingut depengui del codi. El renderitzat al servidor trenca aquesta dependència: el contingut arriba com a contingut, i el codi només hi afegeix interactivitat a sobre.
- El que veuen els cercadors i les targetes de previsualització
Aquest és l'argument que sol decidir la migració, perquè és diners.
Imagina la fitxa pública de bici-002, l'Elèctrica Pro de Plaça Major. A la SPA, l'HTML que arriba és el <div id="root"> buit i un <title>CicloUrbano</title> genèric. Tota la resta —el nom del model, el preu de 4,00 €/h, el barri— apareix després, al DOM.
Què passa amb cada consumidor d'aquest HTML:
| Consumidor | Executa JavaScript? | Què veu de bici-002 |
|---|---|---|
| Googlebot | Sí, però en una segona passada diferida | Pot acabar indexant-lo, amb retard i sense garanties |
| Altres cercadors | Sovint no, o parcialment | Una pàgina buida |
| WhatsApp, Slack, Telegram | No | Títol genèric, sense descripció ni imatge |
| Twitter/X, LinkedIn, Facebook | No | El mateix |
| Lectors d'RSS, agregadors, eines de SEO | Gairebé mai | Res |
El cas de les targetes de previsualització és el més clar i el més fàcil de comprovar. Quan algú enganxa https://ciclourbano.test/bicicletas/bici-002 en un xat, l'aplicació de missatgeria fa una petició HTTP simple i busca a l'HTML les etiquetes <meta property="og:title">, og:description i og:image. No executa JavaScript, ni ara ni mai. Si aquestes etiquetes es posen des de React amb useEffect, l'aplicació de missatgeria mai les veurà.
Amb la SPA, la previsualització de qualsevol bicicleta del catàleg és idèntica:
Amb SSR, cadascuna és la seva:
Elèctrica Pro · Plaça Major — CicloUrbano Bicicleta elèctrica disponible a l'estació Plaça Major (Centre). 4,00 €/h. Reserva per hores des de la web. [imatge de la bicicleta]
La segona es comparteix; la primera no. I això és un problema de negoci, no de rendiment.
- Les quatre estratègies de renderitzat
Aquesta taula és la columna vertebral del mòdul. Tot el que ve a 10-01 i 10-02 són notes al peu d'aquestes quatre files.
| CSR | SSR | SSG | ISR | |
|---|---|---|---|---|
| Nom | Renderitzat al client | Renderitzat al servidor | Generació estàtica | Regeneració incremental |
| Quan es genera l'HTML | Al navegador, després de carregar el JS | A cada petició | Un cop, a next build |
A la construcció i després, cada N segons, a la primera petició després de caducar |
| Qui el genera | El navegador de l'usuari | El servidor Node | La màquina de construcció | El servidor, en segon pla |
| Què es veu primer | Un div buit |
L'HTML complet | L'HTML complet | L'HTML complet |
| Quan queda obsolet | Mai (sempre és fresc) | Mai | Tan bon punt canvien les dades | Com a molt, N segons |
| Cost per visita | Cap al servidor | Un render de servidor | Servir un fitxer | Servir un fitxer (més un render ocasional) |
| Dades personalitzades | Sí | Sí | No | No |
| Contingut idoni | Panells després d'identificar-se | Catàleg amb disponibilitat en viu, resultats de cerca | Pàgines informatives, condicions del servei | Fitxes de bicicleta, llistat d'estacions |
Aplicat a CicloUrbano, la lectura ràpida és:
- El panell de taller (
/taller) és privat, personalitzat per rol i ningú l'indexa: CSR està bé. - El catàleg públic amb disponibilitat en temps real canvia cada pocs minuts i ha de ser indexable: SSR.
- La pàgina de condicions del servei canvia dues vegades l'any: SSG.
- La fitxa d'una bicicleta canvia poc però canvia, i són centenars: ISR.
En aquesta lliçó construïm la columna del SSR. Les dues de la dreta són la lliçó següent.
Una precisió important per no confondre's des del principi: aquestes quatre estratègies no són marcs alternatius entre els quals cal triar-ne un per a tot el projecte. A Next.js amb App Router es trien ruta per ruta, i fins i tot dins d'una mateixa ruta es pot barrejar una part estàtica amb una illa dinàmica. Aquest és precisament el motiu que un framework aporti alguna cosa.
- Què és la hidratació
Amb SSR, el servidor genera l'HTML del catàleg i l'envia. El navegador el pinta immediatament: l'usuari veu les cinc targetes de bicicleta als 400 ms. Però aquest HTML és inert. Els botons no responen, el SelectorTipus no filtra, no hi ha estat, no hi ha gestors.
La hidratació és el procés pel qual React, ja al navegador, recorre aquest HTML existent i li «dona vida»: munta l'arbre de components, crea l'estat i connecta els gestors d'esdeveniments als nodes del DOM que ja hi són, sense tornar a crear-los.
flowchart TB
subgraph SERVIDOR
A["React renderitza l'arbre"] --> B["HTML com a text"]
end
B --> C["El navegador pinta l'HTML<br/>Contingut VISIBLE pero inert"]
C --> D["Arriba el bundle de JavaScript"]
D --> E["hydrateRoot: React recorre el DOM existent"]
E --> F["Associa components a nodes<br/>i enganxa gestors"]
F --> G["Aplicacio INTERACTIVA"]
És útil contrastar-ho amb el que ja coneixes:
createRoot(...).render(...) |
hydrateRoot(...) |
|
|---|---|---|
| Punt de partida | Un contenidor buit | HTML ja generat pel servidor |
| Què fa amb el DOM | El crea sencer | El reutilitza i només l'associa |
| Si no coincideix amb l'esperat | No aplica | Avisa i, en el pitjor cas, el descarta i el torna a crear |
La paraula clau és reutilitza. React parteix de la premissa que l'HTML del servidor és exactament el que ell mateix produiria en el primer render del client. Si aquesta premissa es trenca, apareix el problema de l'apartat següent.
I una conseqüència que convé interioritzar des d'ara: el SSR no elimina el JavaScript. El paquet es continua descarregant i executant. El que fa el SSR és desacoblar el moment en què es veu el contingut del moment en què l'aplicació és interactiva. Entre el FCP i el TTI hi ha ara una finestra en què la pàgina es veu però no respon: és el que s'anomena la «vall inquietant» de la hidratació. Reduir-la és precisament el que persegueixen els React Server Components de 10-03.
- Discrepàncies entre servidor i client
Un error d'hidratació (hydration mismatch) passa quan l'HTML que React genera al client durant el primer render no coincideix amb el que ha arribat del servidor. A React 19 el missatge és explícit i mostra un diferencial:
Hydration failed because the server rendered HTML didn't match the client.
As a result this tree will be regenerated on the client.
<TargetaBicicleta>
<span className="data">
- Reservada fins a les 18:42 ← servidor
+ Reservada fins a les 20:42 ← clientLes causes gairebé sempre són la mateixa família de problemes: alguna cosa que val diferent al servidor i al navegador.
| Causa | Per què falla | Solució |
|---|---|---|
new Date(), toLocaleTimeString() |
El servidor està en UTC i el navegador a la zona de l'usuari | Renderitzar la data en ISO i formatar-la en un efecte, o fixar la zona horària explícitament |
Math.random(), crypto.randomUUID() |
Dues execucions, dos valors | Generar el valor un cop al servidor i passar-lo com a prop; per a identificadors d'accessibilitat, useId |
localStorage, sessionStorage |
No existeixen al servidor | Llegir-los a useEffect, mai durant el render |
window, document, navigator |
No existeixen al servidor | Comprovar typeof window !== 'undefined' o llegir-los a useEffect |
HTML no vàlid (<div> dins d'un <p>) |
El navegador corregeix el marcatge i ja no coincideix | Corregir el marcatge |
| Extensions del navegador | Injecten atributs abans de la hidratació | No és culpa teva; suppressHydrationWarning al node afectat |
El cas més freqüent a CicloUrbano és el tema fosc. El BotoTema llegeix la preferència desada a localStorage, i aquest codi s'executa durant el render:
// INCORRECTE en SSR: localStorage no existeix al servidor
function ProveidorTema({ children }) {
const [tema, setTema] = useState(localStorage.getItem('tema') ?? 'clar');
// ...
}En el servidor això ni tan sols arriba a discrepar: rebenta, perquè localStorage no està definit. El patró correcte separa el valor inicial segur del valor real:
// src/contextos/ProveidorTema.jsx
'use client';
import { createContext, useState, useEffect } from 'react';
export const ContextTema = createContext(null);
function ProveidorTema({ children }) {
// 1. Valor inicial DETERMINISTA: igual al servidor i al client.
const [tema, setTema] = useState('clar');
// 2. Després de la hidratació, i només aleshores, es llegeix la preferència real.
useEffect(() => {
const desat = window.localStorage.getItem('tema');
if (desat) setTema(desat);
}, []);
useEffect(() => {
document.documentElement.dataset.tema = tema;
window.localStorage.setItem('tema', tema);
}, [tema]);
return (
<ContextTema.Provider value={{ tema, setTema }}>
{children}
</ContextTema.Provider>
);
}
export default ProveidorTema;Què fa cada peça:
useState('clar')dona un valor determinista: el servidor i el primer render del client coincideixen, així que la hidratació és neta.- El primer
useEffects'executa després de la hidratació, ja al navegador, onwindowexisteix. Si la preferència desada era'fosc', provoca un segon render i el tema canvia. - El preu d'aquesta correcció és un parpelleig: durant uns mil·lisegons es veu el tema clar. La solució habitual —fora de l'abast d'aquesta lliçó— és un petit script en línia al
<head>que fixadata-temaabans que es pinti res.
Regla pràctica. Durant el render, un component només pot fer servir informació que el servidor també té. Tota la resta —emmagatzematge, mida de la finestra, zona horària, aleatorietat— pertany a
useEffect.
- Per què cal un framework
React porta la peça bàsica: renderToString a react-dom/server converteix un arbre de components en una cadena d'HTML. Amb això i un servidor Express es pot muntar un SSR mínim en vint línies.
I funciona… fins que vols qualsevol d'aquestes coses:
- Enrutament en dos llocs alhora, al servidor i al client, amb les mateixes rutes i sense duplicar la configuració.
- Precarregar les dades de la ruta abans de renderitzar, i després serialitzar-les a l'HTML perquè el client no les torni a demanar.
- Empaquetar dues compilacions diferents, la del servidor i la del client, amb codi compartit i amb CSS que arribi a la primera resposta.
- Dividir el codi per ruta i que el servidor sàpiga quins fragments precarregar a cada pàgina.
- Cachejar l'HTML per ruta, revalidar-lo, i decidir per ruta si és estàtic o dinàmic.
- Streaming, components de servidor, optimització d'imatges, capçaleres,
sitemap.xml…
Cada punt és un projecte en si mateix, i la combinació de tots és un framework. Per això l'ecosistema no escriu SSR a mà: fa servir Next.js, Remix / React Router en mode framework, Astro o TanStack Start. En aquest curs fem servir Next.js, que és el més estès i el que desenvolupa el model de components de servidor que veuràs a 10-03.
- Next.js 15: crear
ciclourbano-web
ciclourbano-webAquí convé ser explícit sobre què estem fent, perquè és fàcil perdre's:
No reescriurem CicloUrbano. L'aplicació de gestió amb Vite —catàleg intern, reserves, taller, Redux, TanStack Query, les proves del mòdul 9— continua existint tal qual. El que creem ara és un segon projecte,
ciclourbano-web, l'aparador públic: les pantalles que es comparteixen, s'indexen i les veu gent que no ha iniciat sessió.
El criteri per decidir què va a l'aparador és senzill i es resumeix en tres preguntes:
| Pregunta | Si la resposta és «sí» | Si és «no» |
|---|---|---|
| La veu algú sense identificar-se? | Candidata a l'aparador | Es queda a la SPA |
| Vols que Google la indexi o que es comparteixi en un xat? | Candidata a l'aparador | Es queda a la SPA |
| És una eina de treball amb molta interacció i estat? | Es queda a la SPA | Candidata a l'aparador |
Amb aquest filtre, es migren el catàleg públic, la fitxa de bicicleta, les pàgines d'estacions i el contingut informatiu. Es queden a Vite el panell de taller, la gestió de reserves i tot el que hi ha darrere de RutaProtegida.
Creació del projecte:
L'assistent pregunta. Aquestes són les respostes per a aquest mòdul:
✔ Would you like to use TypeScript? … No ✔ Would you like to use ESLint? … Yes ✔ Would you like to use Tailwind CSS? … No ✔ Would you like your code inside a `src/` directory? … Yes ✔ Would you like to use App Router? (recommended) … Yes ✔ Would you like to use Turbopack? … Yes ✔ Would you like to customize the import alias (`@/*` by default)? … No
Diem «no» a TypeScript perquè el veurem a 10-04 sobre el projecte Vite, i «no» a Tailwind perquè CicloUrbano ja fa servir CSS Modules, que Next.js suporta de fàbrica. App Router és obligatori per a tot el que ve ara.
El servidor arrenca a http://localhost:3000. Com que l'API fictícia continua sent json-server a http://localhost:3001, els dos processos conviuen sense tocar-se.
L'estructura rellevant després de la creació:
ciclourbano-web/ ├── src/ │ └── app/ │ ├── layout.js ← plantilla arrel: <html> i <body> │ ├── page.js ← la ruta "/" │ ├── globals.css │ └── favicon.ico ├── public/ ← fitxers servits tal qual ├── next.config.mjs ├── jsconfig.json └── package.json
El primer que sorprèn és el que no hi ha: ni main.jsx, ni createRoot, ni index.html, ni rutes.jsx. Next.js s'encarrega de l'arrencada i de les rutes.
Traslladem les variables de CicloUrbano a src/app/globals.css sense canviar cap valor:
/* src/app/globals.css */
:root {
--color-marca: #12805c;
--color-llogada: #b45309;
--color-manteniment: #9b1c1c;
--color-fons: #f5f7fa;
--color-superficie: #ffffff;
--color-text: #1f2933;
--color-vora: #d9e2ec;
}
:root[data-tema='fosc'] {
--color-fons: #10151b;
--color-superficie: #1a222c;
--color-text: #e6edf3;
--color-vora: #2b3542;
}
body {
background: var(--color-fons);
color: var(--color-text);
font-family: system-ui, sans-serif;
margin: 0;
}
- Rutes per carpetes:
layout, page, loading, error, not-found
layout, page, loading, error, not-foundA React Router, una ruta és un objecte a rutes.jsx. A l'App Router de Next.js, una ruta és una carpeta, i dins seu certs noms de fitxer tenen un significat reservat.
| Fitxer | Què és | Equivalent que ja coneixes |
|---|---|---|
page.jsx |
El contingut de la ruta. Sense ell, la carpeta no és navegable | L'element d'una ruta |
layout.jsx |
Embolcall que persisteix entre navegacions filles | Una ruta pare amb <Outlet /> |
loading.jsx |
Es mostra mentre la pàgina carrega | El fallback d'un <Suspense> |
error.jsx |
Captura errors del subarbre | LimitError de 04-05 |
not-found.jsx |
Resposta 404 del subarbre | PaginaNoTrobada |
template.jsx |
Com layout, però es remunta a cada navegació |
— |
route.js |
Gestor HTTP (no interfície) | Un endpoint d'API |
Les carpetes que no contenen cap d'aquests fitxers no creen rutes: serveixen per organitzar. I hi ha dues convencions de nom molt útils:
[bicicletaId]→ segment dinàmic, equivalent a:bicicletaId.(escaparate)→ grup de rutes: agrupa per compartir unlayoutsense afegir res a la URL.
La plantilla arrel és obligatòria i és l'única que conté <html> i <body>:
// src/app/layout.jsx
import './globals.css';
import Capcalera from '@/components/Capcalera';
import PeuDePagina from '@/components/PeuDePagina';
export const metadata = {
title: {
default: 'CicloUrbano · Lloguer de bicicletes urbanes',
template: '%s · CicloUrbano',
},
description: 'Lloga bicicletes urbanes, elèctriques i de càrrega per hores a la teva ciutat.',
};
export default function PlantillaArrel({ children }) {
return (
<html lang="ca">
<body>
<Capcalera />
<main>{children}</main>
<PeuDePagina />
</body>
</html>
);
}Tres coses a destacar:
- La prop es diu
children, no<Outlet />. Next.js hi injecta la pàgina o la plantilla filla. És composició pura, com la del mòdul 4. metadataés un objecte exportat, no un component. Eltemplate: '%s · CicloUrbano'fa que qualsevol pàgina que exportititle: 'Estacions'acabi sentEstacions · CicloUrbano.- Aquest component és un component de servidor: s'executa al servidor i no s'envia al navegador. Ho veurem a l'apartat 11.
- Del mapa de React Router al mapa de Next.js
Aquesta és la traducció literal del rutes.jsx de CicloUrbano a l'arbre de carpetes de l'aparador. Recorda que només migrem la part pública.
| Ruta (React Router) | Fitxer a Next.js | Notes |
|---|---|---|
/ (catàleg, ?tipo=) |
app/page.jsx |
searchParams substitueix useSearchParams |
/bicicletas/:bicicletaId |
app/bicicletas/[bicicletaId]/page.jsx |
params substitueix useParams |
/estaciones |
app/estaciones/page.jsx |
|
/estaciones/:estacionId |
app/estaciones/[estacionId]/layout.jsx |
El layout fa de ruta pare amb pestanyes |
↳ pestanya flota (índex) |
app/estaciones/[estacionId]/page.jsx |
La ruta índex és el page.jsx del mateix nivell |
↳ pestanya incidencias |
app/estaciones/[estacionId]/incidencias/page.jsx |
|
* (no trobada) |
app/not-found.jsx |
Retorna un 404 real, no un 200 |
errorElement |
app/error.jsx |
Ha de ser component de client |
/acceso, /reservas, /taller |
— | Es queden a la SPA de Vite |
L'arbre resultant:
src/app/
├── layout.jsx → Disseny + Capcalera + PeuDePagina
├── page.jsx → PaginaCataleg
├── loading.jsx → EsqueletPagina
├── error.jsx → LimitError
├── not-found.jsx → PaginaNoTrobada
├── bicicletas/
│ └── [bicicletaId]/
│ ├── page.jsx → PaginaFitxaBicicleta
│ └── not-found.jsx → «Aquesta bicicleta no existeix»
└── estaciones/
├── page.jsx → PaginaEstacions
└── [estacionId]/
├── layout.jsx → capçalera de l'estació + pestanyes
├── page.jsx → PestanyaFlota (índex)
└── incidencias/
└── page.jsx → PestanyaIncidenciesCompara-ho amb el diagrama de rutes imbricades del mòdul 6: és el mateix arbre. El que canvia és que a React Router ho declaraves i aquí ho dibuixes amb carpetes. La navegació també té la seva equivalència directa:
| React Router | Next.js |
|---|---|
<Link to="/estaciones"> |
<Link href="/estaciones"> (de next/link) |
useNavigate() |
useRouter() de next/navigation (només al client) |
useParams() |
La prop params de page.jsx (o useParams al client) |
useSearchParams() |
La prop searchParams de page.jsx |
<Outlet /> |
La prop children de layout.jsx |
loader |
El propi component async |
I aquí apareix la primera imbricació real, el layout de l'estació amb les seves pestanyes:
// src/app/estaciones/[estacionId]/layout.jsx
import Link from 'next/link';
import { notFound } from 'next/navigation';
import PestanyesEstacio from '@/components/PestanyesEstacio';
export default async function PlantillaEstacio({ children, params }) {
// A Next.js 15, params és una promesa: cal esperar-la.
const { estacionId } = await params;
const resposta = await fetch(`http://localhost:3001/estaciones/${estacionId}`);
if (!resposta.ok) notFound();
const estacio = await resposta.json();
return (
<section>
<h1>{estacio.nom}</h1>
<p>{estacio.barri} · {estacio.places} places</p>
<PestanyesEstacio estacionId={estacionId} />
{children}
</section>
);
}Punts importants:
paramsés una promesa a Next.js 15. S'espera ambawait. El mateix passa ambsearchParams,cookies()iheaders(). És un canvi respecte a versions anteriors i una font habitual de confusió en llegir tutorials antics.notFound()interromp el render i fa que Next.js serveixi elnot-found.jsxmés proper amb un codi HTTP 404 de veritat. A la SPA,PaginaNoTrobadaes pintava amb un 200; els cercadors no distingien una fitxa esborrada d'una vàlida.- La capçalera de l'estació es renderitza al
layout, així que no es torna a demanar en canviar de pestanya: exactament el comportament que buscàvem amb les rutes imbricades del mòdul 6.
- Components de servidor: dades amb
async/await
async/awaitAquest és el canvi conceptual més gran de la lliçó. A l'App Router, tots els components són components de servidor per defecte, i un component de servidor pot ser async.
Compara. Així demanava CicloUrbano el catàleg amb TanStack Query:
// SPA amb Vite — src/pagines/PaginaCataleg.jsx
import { useQuery } from '@tanstack/react-query';
import LlistaBicicletes from '../components/LlistaBicicletes';
import IndicadorDeCarrega from '../components/IndicadorDeCarrega';
function PaginaCataleg() {
const { data: bicicletes, isPending, isError } = useQuery({
queryKey: ['bicicletas'],
queryFn: () => fetch('http://localhost:3001/bicicletas').then((r) => r.json()),
});
if (isPending) return <IndicadorDeCarrega />;
if (isError) return <p>No s'ha pogut carregar el catàleg.</p>;
return <LlistaBicicletes bicicletes={bicicletes} />;
}I així a Next.js:
// src/app/page.jsx — component de SERVIDOR
import LlistaBicicletes from '@/components/LlistaBicicletes';
export default async function PaginaCataleg() {
const resposta = await fetch('http://localhost:3001/bicicletas', {
cache: 'no-store', // disponibilitat en viu: mai cachejar
});
if (!resposta.ok) {
throw new Error("No s'ha pogut carregar el catàleg.");
}
const bicicletes = await resposta.json();
return <LlistaBicicletes bicicletes={bicicletes} />;
}Què ha desaparegut, i per què:
| Desapareix | Per què |
|---|---|
useQuery i tota la memòria cau de client |
Les dades es resolen abans de renderitzar; no hi ha estat que sincronitzar |
isPending i <IndicadorDeCarrega /> |
La càrrega la gestiona loading.jsx a nivell de ruta |
isError i el missatge d'error |
Un throw puja a l'error.jsx més proper |
El useEffect de peticions |
No hi ha cicle de vida: la funció s'executa un cop, al servidor |
| La cascada petició→render→petició | El servidor ja té les dades quan renderitza |
Hi ha un detall que mereix atenció especial: fetch('http://localhost:3001/...') s'executa al servidor Node, no al navegador. Això té tres conseqüències immediates:
- No hi ha CORS. És una petició de servidor a servidor.
- La URL de l'API pot ser privada. El navegador mai la veu. En producció,
http://api.interna:3001funcionaria perfectament. - Es poden fer servir secrets. Una clau d'API a
process.env.CLAVE_APIno arriba mai al client. Això és un avantatge de seguretat enorme, i també un risc si t'equivoques de costat de la frontera (ho veurem a 10-03).
I el que puja al navegador d'aquest component és res. PaginaCataleg no forma part del paquet de JavaScript del client: només hi viatja el seu resultat. Aquest és el fons de l'assumpte que 10-03 desenvolupa.
Un apunt sobre peticions múltiples. Si la fitxa necessita la bicicleta i la seva estació, no les encadenis si no depenen l'una de l'altra:
// Seqüencial: 120 ms + 120 ms = 240 ms — MALAMENT si són independents
const bicicleta = await obtenirBicicleta(bicicletaId);
const estacions = await obtenirEstacions();
// En paral·lel: max(120, 120) = 120 ms — BÉ
const [bicicleta, estacions] = await Promise.all([
obtenirBicicleta(bicicletaId),
obtenirEstacions(),
]);Quan sí que hi ha dependència real —necessites bicicleta.estacioId per demanar l'estació— la cascada és inevitable, i aquí és on entra el streaming de l'apartat 16.
- La directiva
'use client', en quatre línies
'use client', en quatre líniesUn component de servidor no té estat, ni efectes, ni gestors d'esdeveniments: s'executa una sola vegada, al servidor, i no torna. Per recuperar tot això, es posa 'use client' a la primera línia del fitxer. Això marca aquest mòdul i tot el que importi com a codi de client: s'empaqueta, s'envia al navegador i s'hidrata, i allà tornen a funcionar useState, useEffect, onClick i les APIs del navegador. A CicloUrbano ho portaran SelectorTipus, BotoTema, MenuUsuari i el proveïdor de tema.
La frontera entre servidor i client, les seves regles exactes i el que pot i no pot travessar-la s'expliquen a fons a 10-03. De moment n'hi ha prou amb la regla operativa: si el component necessita estat, efectes o esdeveniments, 'use client' a dalt; si només pinta dades, no el posis.
// src/components/SelectorTipus.jsx
'use client';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
const TIPUS = ['todos', 'urbana', 'electrica', 'carga'];
export default function SelectorTipus() {
const router = useRouter();
const rutaActual = usePathname();
const parametres = useSearchParams();
const tipusActual = parametres.get('tipo') ?? 'todos';
function gestionarCanvi(esdeveniment) {
const tipus = esdeveniment.target.value;
const nous = new URLSearchParams(parametres);
if (tipus === 'todos') nous.delete('tipo');
else nous.set('tipo', tipus);
router.push(`${rutaActual}?${nous}`);
}
return (
<label>
Tipus de bicicleta
<select value={tipusActual} onChange={gestionarCanvi}>
{TIPUS.map((tipus) => (
<option key={tipus} value={tipus}>{tipus}</option>
))}
</select>
</label>
);
}És gairebé idèntic al SelectorTipus del mòdul 6: l'única cosa que canvia són els hooks de next/navigation en lloc dels de react-router. La lògica del filtre a la URL, que tant vam defensar en el seu moment, es trasllada intacta.
- Renderitzat dinàmic: quan Next.js decideix renderitzar a cada petició
Next.js no renderitza al servidor a cada petició per defecte. El seu comportament per defecte és intentar prerenderitzar la ruta en la construcció (això és SSG, i és el tema de 10-02). Només passa a renderitzar a cada petició —renderitzat dinàmic, que és el SSR pròpiament dit— quan detecta que la ruta no es pot conèixer per endavant.
Aquests són els detonants:
| Detonant | Per què obliga a renderitzar a cada petició |
|---|---|
fetch(..., { cache: 'no-store' }) |
Les dades han de ser fresques a cada visita |
await cookies() |
Depèn de qui demana la pàgina |
await headers() |
Depèn de la petició concreta |
La prop searchParams en una page |
Depèn de la URL completa, que no es coneix en construir |
export const dynamic = 'force-dynamic' |
Declaració explícita |
connection() de next/server |
Declaració explícita que s'espera la petició |
Important a Next.js 15: fetch ja no es cacheja per defecte. Si no dius res, es comporta com no-store i la ruta passa a dinàmica. Perquè sigui estàtica cal demanar-ho (cache: 'force-cache' o next: { revalidate: N }). En versions anteriors era al revés, així que molt material antic diu el contrari.
A CicloUrbano, el catàleg públic és el cas de llibre del renderitzat dinàmic: mostra disponibilitat en temps real, així que servir HTML de fa mitja hora seria mentir a l'usuari.
// src/app/page.jsx
import LlistaBicicletes from '@/components/LlistaBicicletes';
import SelectorTipus from '@/components/SelectorTipus';
import ResumFlota from '@/components/ResumFlota';
export const metadata = {
title: 'Catàleg',
description: 'Consulta les bicicletes disponibles ara mateix a cada estació.',
};
export default async function PaginaCataleg({ searchParams }) {
// searchParams és una promesa a Next.js 15.
const { tipo } = await searchParams;
const resposta = await fetch('http://localhost:3001/bicicletas', {
cache: 'no-store',
});
const bicicletes = await resposta.json();
const visibles = tipo && tipo !== 'todos'
? bicicletes.filter((bici) => bici.tipus === tipo)
: bicicletes;
const disponibles = bicicletes.filter((b) => b.estat === 'disponible').length;
return (
<>
<ResumFlota total={bicicletes.length} disponibles={disponibles} />
<SelectorTipus />
<LlistaBicicletes bicicletes={visibles} />
</>
);
}Fixa't en tres decisions:
- El filtratge es fa al servidor. El que arriba al navegador és només l'HTML de les bicicletes visibles. A la SPA es descarregaven totes i es filtraven al client amb
useCatalegFiltrat. SelectorTipusés de client, peròLlistaBicicletesiResumFlotasón de servidor: no tenen estat, només pinten. No es descarreguen.- En canviar el filtre,
router.pushprovoca una nova petició al servidor que retorna l'HTML actualitzat. La navegació continua sent de client —no hi ha recàrrega completa— però el contingut el genera el servidor.
Pots verificar que funciona: Ctrl+U sobre http://localhost:3000/?tipo=electrica i busca «Elèctrica Pro» al codi font. Allà és, a l'HTML, abans de qualsevol JavaScript.
- Metadades i SEO
Tota la promesa de l'apartat 3 es compleix aquí. Next.js ofereix dues vies: l'exportació estàtica metadata, que ja has vist, i la funció generateMetadata per als casos en què el títol depèn de les dades.
// src/app/bicicletas/[bicicletaId]/page.jsx
import { notFound } from 'next/navigation';
import EtiquetaEstat from '@/components/EtiquetaEstat';
import PanellReserva from '@/components/PanellReserva';
async function obtenirBicicleta(bicicletaId) {
const resposta = await fetch(`http://localhost:3001/bicicletas/${bicicletaId}`, {
cache: 'no-store',
});
if (!resposta.ok) return null;
return resposta.json();
}
export async function generateMetadata({ params }) {
const { bicicletaId } = await params;
const bicicleta = await obtenirBicicleta(bicicletaId);
if (!bicicleta) {
return { title: 'Bicicleta no trobada' };
}
const titol = `${bicicleta.model} · ${bicicleta.preuHora.toFixed(2)} €/h`;
const descripcio =
`Bicicleta ${bicicleta.tipus} del model ${bicicleta.model}. ` +
`Lloguer per hores des de ${bicicleta.preuHora.toFixed(2)} € a CicloUrbano.`;
return {
title: titol,
description: descripcio,
alternates: { canonical: `/bicicletas/${bicicleta.id}` },
openGraph: {
title: titol,
description: descripcio,
type: 'website',
url: `https://ciclourbano.test/bicicletas/${bicicleta.id}`,
images: [{ url: `/imatges/${bicicleta.id}.jpg`, width: 1200, height: 630 }],
},
twitter: { card: 'summary_large_image' },
};
}
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} />
<dl>
<dt>Tipus</dt><dd>{bicicleta.tipus}</dd>
<dt>Preu</dt><dd>{bicicleta.preuHora.toFixed(2)} €/h</dd>
<dt>Estació</dt><dd>{bicicleta.estacioId}</dd>
</dl>
<PanellReserva bicicleta={bicicleta} />
</article>
);
}Detalls que importen:
generateMetadatai el component demanen les mateixes dades, i tanmateixjson-servernomés rep una petició: Next.js dedupliqua automàticament elsfetchidèntics dins del mateix render. Això s'anomena request memoization i evita haver de passar dades entre tots dos.openGraphitwitterprodueixen les etiquetes<meta property="og:...">que llegeixen WhatsApp, Slack i LinkedIn. Amb això,bici-002compartida en un xat mostra la seva pròpia targeta.alternates.canonicalevita contingut duplicat si la mateixa fitxa és accessible per diverses URL.- El
titlees combina amb eltemplatede la plantilla arrel: el resultat final ésElèctrica Pro · 4.00 €/h · CicloUrbano.
Per comprovar el resultat sense desplegar res:
- Sessió sense
localStorage
localStorageA la SPA, la sessió de CicloUrbano viu a sliceSessio i es persisteix a localStorage amb useMagatzemLocal. Al servidor aquest mecanisme no existeix, i no és un detall d'implementació: és una conseqüència lògica. El servidor renderitza l'HTML abans que el navegador executi res, així que només pot saber de la sessió el que arribi a la mateixa petició HTTP.
I en una petició HTTP la sessió viatja a les cookies, que es llegeixen amb cookies():
// src/app/layout.jsx (fragment)
import { cookies } from 'next/headers';
import MenuUsuari from '@/components/MenuUsuari';
export default async function PlantillaArrel({ children }) {
// cookies() és asíncron a Next.js 15 i fa la ruta dinàmica.
const magatzem = await cookies();
const sessio = magatzem.get('sesion_ciclourbano');
const usuari = sessio ? JSON.parse(sessio.value) : null;
return (
<html lang="ca">
<body>
<Capcalera>
<MenuUsuari usuari={usuari} />
</Capcalera>
<main>{children}</main>
<PeuDePagina />
</body>
</html>
);
}Comparació dels dos models:
localStorage (SPA) |
Cookies (SSR) | |
|---|---|---|
| Ho veu el servidor? | Mai | Sí, a cada petició |
| Es pot renderitzar el nom de l'usuari a l'HTML? | No | Sí |
| Accessible des de JavaScript | Sempre | Només si no és HttpOnly |
| Protecció davant XSS | Cap | Alta amb HttpOnly |
| Mida | ~5 MB | ~4 KB |
| S'envia a cada petició | No | Sí (cost d'amplada de banda) |
La conclusió pràctica és doble. Primer: el MenuUsuari amb el nom correcte arriba ja a l'HTML, sense el parpelleig de «convidat → Ana Ribera» que tenia la SPA. Segon, i més important: una cookie HttpOnly és més segura que localStorage, perquè un atac de XSS no la pot llegir des de JavaScript. Si l'aparador i la SPA comparteixen sessió, la cookie és el mecanisme correcte per als dos.
Tingues present que fer servir cookies() converteix la ruta en dinàmica, i com que aquí s'usa a la plantilla arrel, tota l'aplicació passa a renderitzar-se a cada petició. És un preu alt. La manera d'evitar-ho —moure la lectura de la cookie a un component petit embolcallat en <Suspense>, deixant la resta estàtica— és material de 10-02 i 10-03.
- Streaming: l'HTML per parts
Queda una peça per anomenar. Amb el que hem vist fins ara, el servidor espera a tenir totes les dades, renderitza tot l'HTML i l'envia de cop. Si la disponibilitat d'una bicicleta triga 800 ms a calcular-se, l'usuari espera 800 ms sense veure res, igual que abans: hem canviat el lloc on s'espera, no el fet d'esperar.
El streaming trenca això: el servidor envia primer el marc de la pàgina —capçalera, títol de la bicicleta, peu— i va enviant els trossos que falten a mesura que es resolen, sense tancar la connexió. A Next.js s'activa de dues maneres:
- Posant un
loading.jsxa la carpeta de la ruta, que Next.js converteix automàticament en un<Suspense fallback={...}>al voltant de la pàgina. - Embolcallant a mà la part lenta en
<Suspense>dins del component.
// src/app/loading.jsx
import EsqueletPagina from '@/components/EsqueletPagina';
export default function Carregant() {
return <EsqueletPagina files={5} />;
}Reconeixeràs el mecanisme: és exactament el <Suspense fallback> de 08-04, aplicat a dades en lloc de a codi. I aquesta és la promesa que va quedar pendent aleshores: el mecanisme complet de Suspense, el streaming de l'HTML i el model de components de servidor que hi ha darrere de tota aquesta lliçó són el contingut de 10-03.
Errors Comuns i Consells
- Creure que el SSR elimina el JavaScript. No ho fa: el paquet es descarrega igual i l'aplicació s'hidrata. El que millora és quan es veu el contingut, no quant pesa. Si el teu problema és el pes, la solució continua sent la del mòdul 8.
- Fer servir
window,documentolocalStoragedurant el render. Al servidor no existeixen i el render falla ambReferenceError. Van dins deuseEffect, sempre. - Posar
'use client'a la plantilla arrel «perquè tot funcioni». És la manera més ràpida d'anul·lar tots els avantatges: tot l'arbre passa a ser de client. La regla és la contrària: la frontera, tan avall com sigui possible. - Oblidar
awaitaparams,searchParams,cookies()oheaders(). A Next.js 15 són promeses. Senseawaitobtindràs un objecte estrany o un avís, i molts tutorials anteriors a la versió 15 els fan servir sense esperar. - Suposar que
fetches cacheja. A Next.js 15 el comportament per defecte és no cachejar. Si vols memòria cau, demana-la explícitament (10-02). - Renderitzar dates amb
toLocaleString()sense fixar zona ni idioma. El servidor està en UTC; el navegador, no. És la causa número u dels errors d'hidratació. Fes servirtimeZoneilocaleexplícits, o formata en un efecte. - Migrar l'aplicació sencera de cop. L'aparador públic i el panell de gestió tenen requisits oposats. Migra el que guanya amb SSR i deixa la resta com a SPA: és una arquitectura legítima, no una solució a mitges.
- No comprovar el resultat a l'HTML cru.
Ctrl+Uocurlsón l'única manera honesta de saber què arriba realment. L'inspector d'elements sempre mostra la pàgina ja hidratada i et donarà una falsa sensació d'èxit. - Consell: no portis TanStack Query a l'aparador per inèrcia. En un component de servidor no aporta res, perquè no hi ha memòria cau de client a gestionar. Continua sent l'eina correcta a la SPA de Vite i a les illes de client que necessitin dades.
Exercicis
Exercici 1. Per a cada pantalla de CicloUrbano, tria l'estratègia de renderitzat més adequada (CSR, SSR, SSG o ISR) i justifica-la en una frase. Indica també si va a ciclourbano-web o es queda a la SPA de Vite.
| Pantalla | Contingut |
|---|---|
Catàleg públic / |
Bicicletes amb disponibilitat en viu |
Fitxa /bicicletas/bici-002 |
Model, preu i estat; canvia diverses vegades al dia |
/condiciones |
Text legal; canvia dues vegades l'any |
/reservas |
Reserves de l'usuari identificat |
/taller |
Panell d'operari amb incidències en viu |
/estaciones |
3 estacions; el nom i el barri no canvien, les places lliures sí |
Exercici 2. Aquest component provoca un error d'hidratació i, a més, una fallada al servidor. Identifica els tres problemes i reescriu-lo.
// src/components/PanellReserva.jsx
function PanellReserva({ bicicleta }) {
const ultimaVisita = localStorage.getItem('ultimaVisita');
const referencia = `REF-${Math.floor(Math.random() * 10000)}`;
const ara = new Date().toLocaleTimeString();
return (
<aside>
<p>Hora actual: {ara}</p>
<p>Referència de reserva: {referencia}</p>
{ultimaVisita && <p>La teva última visita: {ultimaVisita}</p>}
<button onClick={() => alert('Reservant…')}>
Reservar {bicicleta.model}
</button>
</aside>
);
}Exercici 3. Crea la ruta /estaciones/[estacionId]/incidencias de l'aparador. Ha de: demanar les incidències a http://localhost:3001/incidencias?estacioId=<id> sense memòria cau, retornar un 404 real si l'estació no existeix, exportar un generateMetadata amb el títol Incidències de <nom de l'estació>, i mostrar un missatge quan no hi hagi cap incidència. Indica també quins fitxers addicionals crearies en aquesta carpeta i per a què.
Solucions
Solució 1.
| Pantalla | Estratègia | On | Justificació |
|---|---|---|---|
Catàleg / |
SSR | ciclourbano-web |
La disponibilitat ha de ser real a cada visita i la pàgina ha de ser indexable |
Fitxa bici-002 |
ISR | ciclourbano-web |
Canvia poc i són centenars de pàgines: prerenderitzar i revalidar cada pocs minuts dona el millor de tots dos mons |
/condiciones |
SSG | ciclourbano-web |
Contingut fix: generar-lo a cada petició és malbaratar servidor |
/reservas |
CSR | SPA de Vite | Dades privades per usuari, cap valor de SEO, molta interacció |
/taller |
CSR | SPA de Vite | Darrere de RutaProtegida amb rol operario; res a indexar |
/estaciones |
ISR o SSR | ciclourbano-web |
El llistat és gairebé fix, però les places lliures canvien: ISR curt, o SSR si es vol exactitud absoluta |
Solució 2. Els tres problemes són:
localStorage.getItemdurant el render: no existeix al servidor i el render falla ambReferenceError.Math.random(): produeix un valor diferent al servidor i al client → discrepància d'hidratació.new Date().toLocaleTimeString(): el servidor està en UTC i el navegador en una altra zona → una altra discrepància. A més, l'onClickobliga que el component sigui de client.
// src/components/PanellReserva.jsx
'use client';
import { useState, useEffect, useId } from 'react';
function PanellReserva({ bicicleta, referencia }) {
// Identificador estable entre servidor i client.
const idPanell = useId();
// Valors inicials deterministes.
const [ultimaVisita, setUltimaVisita] = useState(null);
const [ara, setAra] = useState(null);
useEffect(() => {
setUltimaVisita(window.localStorage.getItem('ultimaVisita'));
setAra(new Date().toLocaleTimeString('ca-ES'));
window.localStorage.setItem('ultimaVisita', new Date().toISOString());
}, []);
return (
<aside aria-labelledby={idPanell}>
<h2 id={idPanell}>Reserva</h2>
{ara && <p>Hora actual: {ara}</p>}
<p>Referència de reserva: {referencia}</p>
{ultimaVisita && <p>La teva última visita: {ultimaVisita}</p>}
<button onClick={() => alert('Reservant…')}>
Reservar {bicicleta.model}
</button>
</aside>
);
}
export default PanellReserva;Claus de la correcció: la referència aleatòria es genera al servidor i es passa com a prop, així l'HTML i la hidratació coincideixen; l'emmagatzematge i l'hora es llegeixen a useEffect, després d'hidratar; i els valors que encara no existeixen es renderitzen condicionalment per no produir diferències.
Solució 3.
// src/app/estaciones/[estacionId]/incidencias/page.jsx
import { notFound } from 'next/navigation';
import LlistaAvisos from '@/components/LlistaAvisos';
const API = 'http://localhost:3001';
async function obtenirEstacio(estacionId) {
const resposta = await fetch(`${API}/estaciones/${estacionId}`, { cache: 'no-store' });
return resposta.ok ? resposta.json() : null;
}
export async function generateMetadata({ params }) {
const { estacionId } = await params;
const estacio = await obtenirEstacio(estacionId);
if (!estacio) return { title: 'Estació no trobada' };
return {
title: `Incidències de ${estacio.nom}`,
description: `Incidències obertes a l'estació ${estacio.nom} (${estacio.barri}).`,
};
}
export default async function PestanyaIncidencies({ params }) {
const { estacionId } = await params;
// Les dues peticions són independents: en paral·lel.
const [estacio, respostaIncidencies] = await Promise.all([
obtenirEstacio(estacionId),
fetch(`${API}/incidencias?estacioId=${estacionId}`, { cache: 'no-store' }),
]);
if (!estacio) notFound();
const incidencies = await respostaIncidencies.json();
if (incidencies.length === 0) {
return <p>No hi ha incidències obertes a {estacio.nom}.</p>;
}
return <LlistaAvisos avisos={incidencies} />;
}Fitxers addicionals en aquesta carpeta i el seu motiu:
loading.jsx: mentre es demanen les incidències, la capçalera de l'estació (que és allayoutdel nivell superior) continua visible i només la pestanya mostra el seu esquelet.error.jsx: sijson-serverfalla, es captura allà sense tombar la capçalera ni les pestanyes; ha de portar'use client'i rep les propserrorireset.not-found.jsx: opcional, per donar un missatge específic d'estació inexistent en lloc del genèric de l'arrel.
Fixa't en l'elegància de la imbricació: els tres fitxers només afecten el subarbre de la pestanya, exactament com els LimitError per zona del mòdul 4.
Conclusió
Aquesta lliçó ha travessat la frontera que anunciava el tancament del mòdul 9. El punt de partida era un <div id="root"> buit i tres viatges encadenats —HTML, JavaScript, dades— abans de veure una bicicleta; el punt d'arribada és un servidor que envia l'HTML de bici-002 ja construït, amb el seu títol, el seu preu i les seves etiquetes Open Graph, a punt per al navegador, per a Google i per a la targeta de previsualització d'un xat.
Queden fixades les quatre estratègies de renderitzat, que són el mapa de tot el mòdul: CSR genera l'HTML al navegador i serveix per al que és privat i interactiu; SSR el genera a cada petició i serveix per al que és fresc i indexable; SSG el genera un cop en la construcció; i ISR el regenera cada cert temps. I queda clar que no se'n tria una per a tot el projecte, sinó una per ruta.
Del funcionament, l'essencial és la hidratació: el servidor envia HTML inert i React el reutilitza al navegador per enganxar-hi estat i gestors. D'aquí surt l'única regla que cal interioritzar de veritat: durant el render, un component només pot fer servir informació que el servidor també té. Data local, Math.random(), localStorage, window i la mida de la finestra viuen a useEffect, no al cos del component.
En eines queda en marxa ciclourbano-web, un projecte Next.js 15 amb App Router que conviu amb la SPA de Vite sense substituir-la, amb el criteri de repartiment explícit: a l'aparador va el que és públic, indexable i compartible —catàleg, fitxa de bicicleta, estacions, informatives—; a la SPA es queden /acceso, /reservas i /taller. Les rutes es dibuixen amb carpetes (page, layout, loading, error, not-found, [bicicletaId]), l'<Outlet /> es converteix en children, les dades es demanen amb async/await dins del propi component de servidor —sense useEffect, sense useQuery, sense estats de càrrega manuals—, i params, searchParams, cookies() i headers() són promeses a Next.js 15. El renderitzat dinàmic s'activa quan la ruta no es pot conèixer per endavant, i el catàleg amb disponibilitat en viu l'activa amb cache: 'no-store'. El SEO deixa de ser un problema amb metadata i generateMetadata, i la sessió passa de localStorage a cookies, que a més és l'opció més segura.
Han quedat dos deutes explícits, i tots dos es paguen aviat. El primer és la directiva 'use client', que aquí has fet servir com una regla operativa i que a 10-03 s'explica com el que és: la frontera entre dos mons. La segona és el streaming, que hem anomenat sense desenvolupar.
Però abans cal tancar la meitat dreta de la taula d'estratègies. Fixa't que en tota la lliçó hem forçat el renderitzat dinàmic amb cache: 'no-store', com si el servidor hagués de treballar a cada visita. Per al catàleg amb disponibilitat en viu té sentit; per a les condicions del servei, que canvien dues vegades l'any, és un malbaratament absurd: s'està generant el mateix HTML milers de vegades al dia. La pròxima lliçó va a l'altre extrem —generar l'HTML una sola vegada, en la construcció— i després busca el punt intermedi que resol el 90 % dels casos reals: la regeneració incremental. La pròxima lliçó és Generació de Llocs Estàtics (SSG) amb Next.js.
Curs de React
Mòdul 1: Introducció a React
- Què és React?
- Configuració de l'Entorn de Desenvolupament
- Hola Món amb React
- JSX: Extensió de Sintaxi de JavaScript
- Com Renderitza React: Virtual DOM i Reconciliació
Mòdul 2: Components de React
- Entendre els Components
- Components Funcionals vs de Classe
- Props: Passar Dades als Components
- State: Gestió de l'Estat del Component
- Estils en els Components: CSS, Mòduls i Utilitats
Mòdul 3: Treballar amb Esdeveniments
- Gestió d'Esdeveniments a React
- Renderitzat Condicional
- Llistes i Claus
- Formularis i Components Controlats
- Validació de Formularis i Components No Controlats
- Accessibilitat en Components Interactius
Mòdul 4: Conceptes Avançats de Components
- Elevar l'Estat
- Composició vs Herència
- Mètodes del Cicle de Vida de React
- Hooks: Introducció i Ús Bàsic
- Límits d'Error: Capturar Fallades a la Interfície
Mòdul 5: Hooks de React
- Hook useState
- Hook useEffect
- Hook useRef i Accés al DOM
- Hook useContext
- Hook useReducer
- Hooks Personalitzats
Mòdul 6: Enrutament a React
- Introducció a React Router
- Configuració de React Router
- Rutes Imbricades
- Navegació Programàtica
- Rutes Protegides i Control d'Accés
Mòdul 7: Gestió de l'Estat
- Introducció a la Gestió de l'Estat
- API de Context
- Redux: Introducció i Configuració
- Redux: Accions i Reductors
- Redux: Connectar-lo a React
- Estat del Servidor: Peticions, Memòria Cau i Sincronització
Mòdul 8: Optimització del Rendiment
- Tècniques d'Optimització del Rendiment a React
- Memoïtzació amb React.memo
- Hooks useMemo i useCallback
- Divisió de Codi i Càrrega Mandrosa
- Mesurar el Rendiment amb React DevTools Profiler
Mòdul 9: Proves a React
- Introducció a les Proves
- Proves Unitàries amb Jest
- Proves de Components amb React Testing Library
- Proves de Codi Asíncron i Simulació d'APIs
- Proves d'Extrem a Extrem amb Cypress
Mòdul 10: Temes Avançats
- Renderitzat al Servidor (SSR) amb Next.js
- Generació de Llocs Estàtics (SSG) amb Next.js
- Suspense i React Server Components
- TypeScript amb React
- React Native: Creació d'Aplicacions Mòbils
