Módulo 06: Estilos
Objetivos del módulo
- Entender qué hace Next con tus hojas de estilo, desde el
importhasta el<link>que recibe el navegador - Usar CSS global sabiendo dónde importarlo y por qué no conviene repartirlo por toda la aplicación
- Dominar los CSS Modules: ámbito local,
composes,:global, variantes con atributos y valores dinámicos con variables de CSS - Configurar Tailwind CSS 4 en Next.js 16.4, entender el cargador de Turbopack y evitar el fallo que rompe los CSS Modules en la plantilla
- Usar Sass en ficheros globales y en módulos, con parciales, mixins,
additionalDatay sus límites con Turbopack - Saber qué pasa con las bibliotecas de CSS-in-JS en el App Router
- Cargar fuentes con
next/font(Google y locales) sin peticiones a terceros y sin saltos de diseño - Construir un modo oscuro con tres opciones (claro, oscuro, sistema) que no parpadea al cargar
- Montar el aspecto del catálogo de la tienda EMPRESA con todo lo anterior
💡 Consejo: Este módulo no reexplica las utilidades de Tailwind. Si necesitas repasar
flex,grid, colores, variantes o@theme, tienes el Curso de Tailwind CSS. Aquí nos centramos en lo que es propio de Next: cómo se integra cada técnica, qué se compila, qué llega al navegador y qué falla.
Todo lo que ves en este módulo se ha compilado y ejecutado con Next.js 16.4 (instala siempre el último parche 16.4.x), React 19.3, Tailwind 4.3 y Sass 1.107. Las alternativas para Next.js 15 se han comprobado con Next.js 15.5.
1. Qué hace Next con tus estilos
En una aplicación de React con Vite, importar un CSS es sencillo: Vite lo mete en la página y listo. En Next la pregunta tiene más capas, porque parte de tus componentes se ejecuta en el servidor y nunca llega al navegador. ¿Qué pasa entonces con el import './tarjeta.css' de un Server Component?
La respuesta corta: el CSS se separa del JavaScript al compilar. Da igual que lo importe un Server Component o un Client Component. El bundler (Turbopack, por defecto en Next 16) sigue los import, extrae los estilos, los procesa y los agrupa en ficheros .css. Después, al generar el HTML, Next añade un <link rel="stylesheet"> por cada fichero que necesita esa ruta.
Figura 1. Lo que escribes, lo que hace Turbopack al compilar y lo que llega al navegador.
Este es el <head> real de la página de inicio del laboratorio de este módulo, recortado:
<head>
<meta charSet="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="stylesheet" href="/_next/static/chunks/2-az5j914hpnm.css" data-precedence="next"/>
<link rel="stylesheet" href="/_next/static/chunks/41yl2pp67bcul.css" data-precedence="next"/>
...
<script>(function () { ... })();</script>
</head>
Fíjate en tres cosas:
- Son ficheros normales. El navegador los descarga y los guarda en caché como cualquier CSS. Un Server Component no añade ni un byte de JavaScript por tener estilos.
- El atributo
data-precedence. Next usa el soporte de hojas de estilo de React 19: React coloca los<link>en el<head>, los ordena por precedencia y no los repite aunque varios componentes importen el mismo fichero. - Los nombres tienen un hash. Si cambias el CSS, cambia el nombre del fichero, y el navegador no puede usar una versión vieja de la caché.
📘 Concepto: Un chunk (trozo) es cada fichero que genera el bundler. Next intenta que cada ruta descargue solo el CSS que usa, pero tampoco quiere que haga veinte peticiones. El equilibrio lo controla la opción
cssChunking, que verás en el apartado 10.
1.1. Las opciones que tienes
| Técnica | Ámbito | Cuándo usarla |
|---|---|---|
| CSS global | Toda la aplicación | Variables de tema, reset, estilos de body, la entrada de Tailwind |
CSS Modules (.module.css) | El componente que lo importa | CSS a medida que no quieres que choque con nada |
| Tailwind CSS | Clases de utilidad en el JSX | La mayor parte del diseño; es la opción por defecto de create-next-app |
Sass (.scss, .module.scss) | Global o de módulo | Proyectos que ya lo usan, o si quieres mixins y bucles al compilar |
| CSS-in-JS | El componente | Bibliotecas como styled-components o MUI; solo en Client Components |
Atributo style | Un elemento | Valores que se calculan en tiempo de ejecución (mejor con variables de CSS) |
La documentación de Next recomienda una combinación concreta, y es la que seguirá este curso: CSS global solo para lo verdaderamente global, Tailwind para casi todo, y CSS Modules cuando Tailwind se queda corto.
2. CSS global
2.1. Dónde se importa
Un CSS global se importa como un módulo más, sin asignarlo a nada:
// app/layout.tsx
import './globals.css'
export default function RootLayout({ children }: LayoutProps<'/'>) {
return (
<html lang="es">
<body>{children}</body>
</html>
)
}
Al importarlo en el layout raíz, se aplica a todas las rutas. Técnicamente puedes importar un CSS global desde cualquier layout, página o componente de app/, pero hay un detalle que conviene conocer antes de hacerlo.
⚠️ Importante: Next no retira las hojas de estilo globales al navegar entre rutas. Si
app/tienda/layout.tsximportatienda.csscon unh1 { color: red }, y luego navegas a/admincon<Link>, eseh1rojo sigue activo en/admin. Con una carga completa (recargar la página) no pasaría, y por eso el fallo parece aleatorio. Regla práctica: los CSS globales, solo en el layout raíz. Para estilos de una sección, usa CSS Modules o Tailwind.
2.2. El globals.css de la plantilla
create-next-app genera este app/globals.css:
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
Hay tres ideas que reutilizarás en todo el módulo:
- Variables de CSS como fuente de verdad. Los colores se definen una vez en
:rooty cambian según el tema. Nada en los componentes sabe si el tema es claro u oscuro: solo usavar(--background). @theme inlineconecta esas variables con Tailwind, de modo quebg-backgroundotext-foregroundfuncionan como cualquier color. Elinlinehace que la utilidad usevar(--background)directamente, en lugar de crear otra variable intermedia. Lo tienes explicado a fondo en el módulo 14 del Curso de Tailwind.--font-geist-sansno está definida en ningún CSS: la creanext/fonten el layout. Lo verás en el apartado 7.
Fíjate también en el font-family: Arial del body. Es un pequeño despiste de la plantilla: el layout aplica la variable de la fuente Geist, pero el body fuerza Arial. Si quieres ver Geist, cambia esa línea por font-family: var(--font-sans) o usa la clase font-sans en el body.
2.3. Hojas de estilo de paquetes
Las hojas de estilo que publican paquetes de npm se importan igual, desde cualquier sitio de app/:
// app/layout.tsx
import 'bootstrap/dist/css/bootstrap.css'
import './globals.css'
El orden importa: lo que se importa después gana a igualdad de especificidad. Por eso los estilos de terceros van antes que los tuyos, para que puedas sobrescribirlos.
React 19 también permite enlazar una hoja de estilo externa directamente con <link rel="stylesheet" href="..." precedence="default" /> dentro de cualquier componente; React la sube al <head> y no la duplica. Úsalo para CSS que vive fuera de tu proyecto (una CDN, por ejemplo). Para el tuyo, el import es mejor: pasa por el bundler, se minifica y entra en la caché con hash.
2.4. scroll-behavior: smooth 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15, si ponías
scroll-behavior: smoothenhtml, Next lo desactivaba durante cada navegación para que el salto al principio de la página fuera instantáneo; en 16 ya no lo toca y la navegación también se desplaza suavemente. Si usas Next.js 15: no tienes que hacer nada, ese es el comportamiento por defecto; en 16, añadedata-scroll-behavior="smooth"a la etiqueta<html>para recuperarlo.
<html lang="es" data-scroll-behavior="smooth">
Si tu globals.css tiene html { scroll-behavior: smooth } y al migrar notas que cada clic en un enlace provoca un desplazamiento lento hasta arriba, ese es el motivo.
3. CSS Modules
3.1. La idea
El problema del CSS global es que todo nombre de clase compite con todos los demás. Si dos personas escriben .tarjeta en dos ficheros, gana la que se cargue después, y el fallo aparece en una página que ninguna de las dos ha tocado.
Un CSS Module resuelve esto al compilar: cada clase se renombra con un prefijo único por fichero. Tú escribes .tarjeta, y el navegador recibe algo como Poillq_tarjeta. Desde el componente no usas el nombre a mano: lo pides a un objeto.
Figura 2. Mismo nombre en dos módulos, dos clases distintas en el navegador.
Un fichero es un CSS Module si su nombre termina en .module.css (o .module.scss). Este es el de la tarjeta de producto del catálogo EMPRESA:
/* app/_componentes/base.module.css */
.caja {
background: var(--superficie);
border: 1px solid var(--borde);
border-radius: 12px;
padding: 1rem 1.25rem;
}
/* app/_componentes/TarjetaProducto.module.css */
@reference "../globals.css";
.tarjeta {
composes: caja from "./base.module.css";
display: flex;
flex-direction: column;
gap: 0.5rem;
transition: transform 150ms, box-shadow 150ms;
}
.tarjeta:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px rgb(0 0 0 / 0.08);
}
.nombre {
@apply text-lg font-semibold;
}
.precio {
font-family: var(--font-codigo);
color: var(--marca);
font-size: 1.25rem;
}
/* Variante por atributo: <article data-agotandose> */
.tarjeta[data-agotandose] {
border-color: var(--color-amber-500);
}
.aviso {
@apply rounded-md bg-amber-100 px-2 py-0.5 text-xs text-amber-900;
align-self: flex-start;
}
/* :global sale del ámbito local: aquí, el tema oscuro del <html> */
:global([data-tema="oscuro"]) .aviso {
@apply bg-amber-900 text-amber-100;
}
Y el componente que lo usa. Es un Server Component: los CSS Modules funcionan igual en los dos tipos.
// app/_componentes/TarjetaProducto.tsx
import { formatoPrecio, type Producto } from '../lib/productos'
import estilos from './TarjetaProducto.module.css'
export function TarjetaProducto({ producto }: { producto: Producto }) {
const agotandose = producto.stock < 12
return (
<article className={estilos.tarjeta} data-agotandose={agotandose || undefined}>
<h2 className={estilos.nombre}>{producto.nombre}</h2>
<p className="text-sm text-texto-suave">{producto.descripcion}</p>
<p className={estilos.precio}>{formatoPrecio.format(producto.precio)}</p>
{agotandose && <span className={estilos.aviso}>Quedan {producto.stock}</span>}
</article>
)
}
3.2. Qué hay dentro del objeto
¿Qué es exactamente estilos? Un objeto normal de JavaScript cuyas claves son tus nombres de clase y cuyos valores son los nombres reales. La página /modulos del laboratorio lo imprime con JSON.stringify:
<pre className={estilos.codigo}>{JSON.stringify(tarjeta, null, 2)}</pre>
Esta es la salida real en una compilación de producción con Next.js 16.4:
{
"aviso": "Poillq_aviso",
"nombre": "Poillq_nombre",
"precio": "Poillq_precio",
"tarjeta": "Poillq_tarjeta _3k4EWa_caja"
}

Figura 3. La página /modulos: el objeto que devuelve el import y valores dinámicos con variables de CSS.
Varias lecciones salen de esta salida:
composesno copia el CSS: añade otra clase.tarjetavale"Poillq_tarjeta _3k4EWa_caja", dos clases. Los estilos de.cajaexisten una sola vez en el CSS final, aunque los compongan veinte módulos.- Todas las clases del fichero aparecen, se usen o no. Si una clase no está en el objeto, es que no está en el fichero (o que has escrito mal su nombre).
- El prefijo es el mismo para todo el fichero (
Poillq_), y distinto en otro fichero (_3k4EWa_). - El HTML lleva los nombres reales. El navegador nunca ve
tarjeta, sinoclass="Poillq_tarjeta _3k4EWa_caja".
3.3. Nombres más cortos en producción 🔄 16.4
🔄 Cambia en Next.js 16.4. En Next.js 15 los nombres generados en producción son largos, como
TarjetaProducto-module__KxBoHG__tarjeta; desde 16.4 Turbopack los acorta aPoillq_tarjeta, y el HTML y el CSS pesan menos. Si usas Next.js 15: no hay nada que hacer ni que cambiar en tu código; solo pierdes ese ahorro de bytes, y los nombres siguen siendo únicos.
Es el mismo componente compilado en los dos laboratorios:
<!-- Next.js 15.5 -->
<article class="TarjetaProducto-module__KxBoHG__tarjeta base-module__qg29UW__caja">
<!-- Next.js 16.4 -->
<article class="Poillq_tarjeta _3k4EWa_caja">
La consecuencia práctica es importante: nunca escribas a mano un nombre generado, ni en un test, ni en un selector de Playwright, ni en otro CSS. Cambia entre desarrollo y producción, y entre versiones. Para seleccionar un elemento en una prueba, usa su rol, su texto o un atributo data- propio.
3.4. Reglas de los selectores
Un CSS Module admite casi todo el CSS, con una condición: cada selector debe contener al menos una clase o un identificador local. Por eso esto funciona:
.tarjeta[data-agotandose] { ... } /* clase local + atributo */
.ficha h3 { ... } /* clase local + etiqueta descendiente */
:global([data-tema="oscuro"]) .aviso { ... } /* parte global + clase local */
Y esto no tiene sentido en un módulo:
h1 { color: red; } /* no hay nada local: sería global de verdad */
Si necesitas estilos de etiqueta sin clase, ese CSS es global y va en globals.css.
:global(...) marca una parte del selector como global, sin renombrar. Su uso típico es reaccionar a algo que está fuera del componente: un atributo del <html>, una clase que pone una biblioteca externa, o el estado .abierto que añade otro componente.
3.5. Variantes: atributos mejor que clases sueltas
Para una tarjeta “agotándose” podrías crear .tarjetaAgotandose y elegir entre dos clases en el JSX. Funciona, pero hay una forma más limpia: usar un atributo de datos y un selector de atributo.
<article className={estilos.tarjeta} data-agotandose={agotandose || undefined}>
.tarjeta[data-agotandose] { border-color: var(--color-amber-500); }
¿Por qué agotandose || undefined y no agotandose sin más? Porque React escribe data-agotandose="false" si le pasas false, y el selector [data-agotandose] solo comprueba que el atributo exista. Con undefined, React no escribe el atributo. Este es el HTML real:
<article class="Poillq_tarjeta _3k4EWa_caja">
<article class="Poillq_tarjeta _3k4EWa_caja" data-agotandose="true">
Con atributos de varios valores, como data-nivel="bajo|normal|agotado", lo tienes resuelto en el ejercicio 2.
3.6. Combinar clases
Tarde o temprano necesitarás varias clases a la vez, algunas condicionales. Una función de tres líneas basta:
// app/lib/clases.ts
// Une nombres de clase descartando los falsos: unirClases('a', cond && 'b')
export function unirClases(...clases: (string | false | null | undefined)[]) {
return clases.filter(Boolean).join(' ')
}
<div className={unirClases('rounded-lg p-2', v.actual && estilos.activo)}>
El resultado real para la fila de febrero, la única con actual: true:
<div class="rounded-lg p-2">
<div class="rounded-lg p-2 ERlT8q_activo">
<div class="rounded-lg p-2">
Fíjate en que mezclamos utilidades de Tailwind (rounded-lg p-2) con una clase de módulo. Es perfectamente normal. Las bibliotecas clsx y classnames hacen lo mismo con más opciones; si ya las usas, sigue con ellas.
3.7. Valores dinámicos: variables de CSS, no clases inventadas
Un CSS Module se compila una vez. No puedes generar .ancho-45 porque un dato vale 45. La solución correcta es pasar el valor como variable de CSS en el atributo style, y dejar que el CSS la use:
/* app/modulos/modulos.module.css */
.barra {
height: 0.75rem;
border-radius: 999px;
background: var(--borde);
overflow: hidden;
}
/* El ancho llega desde React como variable de CSS */
.relleno {
height: 100%;
width: var(--progreso, 0%);
background: var(--marca);
}
import type { CSSProperties } from 'react'
<div className={estilos.relleno} style={{ '--progreso': `${v.porcentaje}%` } as CSSProperties} />
HTML real:
<div class="ERlT8q_barra"><div class="ERlT8q_relleno" style="--progreso:45%"></div></div>
¿Por qué no style={{ width: '45%' }} directamente? Para este caso tan simple daría igual. Pero la variable separa el dato (45 %) de la presentación (que sea un ancho, una altura o un ángulo), y el CSS puede usarla en varias reglas, en @media o en transiciones. El as CSSProperties es necesario porque TypeScript no conoce tus propiedades personalizadas.
3.8. TypeScript y los módulos
¿Cómo sabe TypeScript que import estilos from './x.module.css' devuelve un objeto? Por next-env.d.ts, que incluye los tipos de Next, y estos declaran los módulos *.module.css y *.module.scss como un objeto de cadenas ({ readonly [key: string]: string }).
Eso tiene un efecto que debes conocer: TypeScript no comprueba que la clase exista. estilos.tarjta (con una errata) compila sin error, vale undefined y React no escribe el atributo class. El elemento sale sin estilo y sin ningún aviso. Cuando un estilo de módulo “no se aplica”, lo primero es mirar el HTML: si el elemento no tiene class, la clave está mal escrita.
💡 Consejo: Con nombres en kebab-case como
.estado-pendiente, tendrás que usar corchetes:estilos['estado-pendiente']. Si prefieresestilos.estadoPendiente, escribe las clases en camelCase en el CSS. Es una cuestión de gusto, pero elige una convención para todo el proyecto.
4. Tailwind CSS 4 con Turbopack 🔄 16.4
🔄 Cambia en Next.js 16.4. Hasta 16.3, y en Next.js 15,
create-next-appintegra Tailwind con PostCSS (@tailwindcss/postcssypostcss.config.mjs); desde 16.4 usa un cargador de Turbopack (@tailwindcss/turbopack) configurado ennext.config.ts. Si usas Next.js 15: sigue con PostCSS, que es lo que trae tu plantilla y funciona con CSS Modules sin cambios; el resto del apartado (clases,@theme,@reference) es igual.
4.1. Lo que genera create-next-app
Con Next.js 16.4, create-next-app --yes deja Tailwind configurado así:
// next.config.ts (plantilla de 16.4)
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
cacheComponents: true,
partialPrefetching: true,
turbopack: {
rules: {
"*.css": {
loaders: ["@tailwindcss/turbopack"],
as: "*.css",
},
},
},
};
export default nextConfig;
/* app/globals.css */
@import "tailwindcss";
Y en package.json aparecen tailwindcss y @tailwindcss/turbopack como dependencias de desarrollo. No hay postcss.config.mjs.
¿Qué significa esa regla? turbopack.rules le dice a Turbopack: “a todo fichero que encaje con *.css, pásale este cargador antes de procesarlo”. Un cargador (loader) es una función que recibe el contenido de un fichero y devuelve otro. El de Tailwind lee tu CSS, encuentra @import "tailwindcss", recorre el proyecto buscando nombres de clase y genera el CSS de las utilidades que de verdad usas. as: "*.css" indica qué tipo de fichero sale del cargador.
En Next.js 15 (y hasta 16.3) la plantilla hacía lo mismo con PostCSS:
// postcss.config.mjs (plantilla de Next.js 15)
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
El resultado para las utilidades es el mismo. La diferencia es quién ejecuta Tailwind: un plugin de PostCSS o un cargador nativo de Turbopack.
4.2. El fallo: la regla de la plantilla rompe los CSS Modules
Aquí viene el descubrimiento más importante del módulo, y el que más tiempo te puede hacer perder. Con la configuración tal cual la genera create-next-app 16.4, los CSS Modules dejan de funcionar.
Este es el síntoma real. La misma página /modulos del apartado 3.2, compilada con la regla de la plantilla, imprime:
{}
El objeto está vacío. Y el HTML sale sin clases:
<h1>
<article>
<article data-agotandose="true">
Si además el módulo usa :global(...), el next build lo avisa con un mensaje que despista bastante:
./app/_componentes/TarjetaProducto.module.css.css:37:9
Warning: Parsing CSS source code failed
> 37 | :global([data-tema="oscuro"]) .aviso {
| ^
'global' is not recognized as a valid pseudo-class. Did you mean '::global' (pseudo-element) or is this a typo?
Mira el nombre del fichero: TarjetaProducto.module.css.css. Esa es la pista. La regla "*.css" encaja también con TarjetaProducto.module.css, el cargador lo procesa, y as: "*.css" hace que su salida se trate como un fichero .css normal, no como un módulo. Turbopack ya no renombra las clases, el import devuelve un objeto vacío y :global deja de ser una palabra especial para convertirse en un error de sintaxis. Con los .module.scss pasa lo mismo, porque la salida del compilador de Sass también acaba encajando en *.css.
El build no falla. Las páginas se generan. Solo que nada tiene estilo. Es el peor tipo de error.
⚠️ Importante: Si en un proyecto de Next.js 16.4 recién creado tus CSS Modules “no hacen nada” y
console.log(estilos)imprime{}, no es culpa de tu CSS: es la regla de Tailwind denext.config.ts. Aplica una de las dos soluciones siguientes.
4.3. Solución A: una regla para los globales y otra para los módulos
Puedes conservar el cargador de Turbopack separando los dos casos con condiciones. La salida de un módulo debe seguir terminando en .module.css:
// next.config.ts
import type { NextConfig } from "next";
const tailwind = "@tailwindcss/turbopack";
const esModulo = { path: /\.module\.css$/ };
const esSass = { path: /\.s[ac]ss/ };
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
turbopack: {
rules: {
"*.css": [
// CSS global: como en la plantilla
{ condition: { not: esModulo }, loaders: [tailwind], as: "*.css" },
// CSS Modules: la salida debe seguir terminando en .module.css
{ condition: { all: [esModulo, { not: esSass }] }, loaders: [tailwind], as: "*.module.css" },
],
},
},
};
export default nextConfig;
Cada elemento de la lista es una regla con su condition. Las condiciones admiten path (cadena o expresión regular), y combinarlas con all, any y not; lo puedes comprobar en los tipos de next/dist/server/config-shared.d.ts. Hemos excluido Sass del cargador porque los módulos de Sass no necesitan Tailwind y así se evita procesarlos dos veces.
Con esta configuración, el objeto vuelve a tener sus clases ("tarjeta": "Poillq_tarjeta _3k4EWa_caja"), :global funciona y @apply dentro de los módulos también. Es la configuración del laboratorio de este módulo.
4.4. Solución B: volver a PostCSS
La otra opción es la que describe la propia documentación de Next en la guía de CSS: quitar la regla de next.config.ts y usar el plugin de PostCSS, como en Next.js 15.
npm uninstall @tailwindcss/turbopack
npm install -D @tailwindcss/postcss
// postcss.config.mjs
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
// next.config.ts: sin la sección turbopack.rules
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
};
export default nextConfig;
Comprobado en el laboratorio con Next.js 16.4 y Turbopack: los módulos vuelven a tener sus clases (<article class="uWudFG_tarjeta _2ofgGq_caja">) y las utilidades de Tailwind se generan igual.
¿Cuál elegir? Si vas a usar CSS Modules (y en este curso los usamos), la B es la más sencilla y la que tiene menos sorpresas: es el camino documentado, funciona igual en 15 y en 16, y no depende de entender las reglas de Turbopack. La A te deja en la configuración nueva de la plantilla. Las dos están probadas.
4.5. Cómo encuentra Tailwind tus clases
Tailwind 4 no necesita tailwind.config.js ni una lista de rutas content. Recorre los ficheros del proyecto (respetando .gitignore) y busca cadenas que parezcan clases. No ejecuta tu código: lo lee como texto.
De ahí sale el error clásico. Esto no funciona:
// MAL: la cadena "bg-emerald-100" no aparece en ningún fichero
<span className={`bg-${color}-100`}>
Tailwind ve bg-${color}-100, que no es ninguna clase, y no genera nada. La solución es que la clase completa aparezca literalmente en el código:
// BIEN: cada clase completa está escrita en el fichero
const claseEstado: Record<Estado, string> = {
pendiente: 'bg-amber-100 text-amber-900',
enviado: 'bg-blue-100 text-blue-900',
entregado: 'bg-emerald-100 text-emerald-900',
cancelado: 'bg-red-100 text-red-900',
}
Lo tienes completo en el ejercicio 8. Si un valor de verdad es libre (un color que elige el usuario), usa una variable de CSS como en el apartado 3.7. Para las directivas que amplían la búsqueda (@source) o generan clases que no aparecen en el código, consulta el módulo 15 del Curso de Tailwind.
💡 Consejo: El cargador de Turbopack acepta la opción
base(carpeta desde la que buscar clases, por defecto el directorio de trabajo) yoptimize(minificar; por defecto activo en producción). Se pasan con la forma de objeto:loaders: [{ loader: "@tailwindcss/turbopack", options: { base: process.cwd() } }]. Es útil en monorepos, donde el código vive en otras carpetas.
4.6. Tus tokens en @theme
Para que la tienda EMPRESA tenga sus propios colores y fuentes como utilidades, definimos variables normales y las exponemos a Tailwind con @theme inline. Este es el globals.css completo del laboratorio:
@import "tailwindcss";
/* La variante dark: depende del atributo data-tema, no de la preferencia del sistema */
@custom-variant dark (&:where([data-tema="oscuro"], [data-tema="oscuro"] *));
/* Colores de la marca EMPRESA como variables normales de CSS */
:root {
--fondo: #f5f7fb;
--superficie: #ffffff;
--texto: #1f2937;
--texto-suave: #6b7280;
--marca: #3f51b5;
--borde: #e5e7eb;
color-scheme: light;
}
[data-tema="oscuro"] {
--fondo: #0f172a;
--superficie: #1e293b;
--texto: #e5e7eb;
--texto-suave: #94a3b8;
--marca: #8c9eff;
--borde: #334155;
color-scheme: dark;
}
/* Las exponemos a Tailwind: bg-fondo, text-marca, font-codigo... */
@theme inline {
--color-fondo: var(--fondo);
--color-superficie: var(--superficie);
--color-texto: var(--texto);
--color-texto-suave: var(--texto-suave);
--color-marca: var(--marca);
--color-borde: var(--borde);
--font-sans: var(--font-inter);
--font-codigo: var(--font-codigo);
}
body {
background: var(--fondo);
color: var(--texto);
}
Con esto, bg-superficie, text-texto-suave, border-borde o font-codigo son utilidades como cualquier otra. Y como apuntan a variables que cambian con data-tema, el modo oscuro de toda la aplicación sale gratis: ningún componente necesita dark: para los colores de la marca. Reserva dark: para los casos especiales (una sombra más marcada, una imagen distinta).
color-scheme indica al navegador qué esquema usar para lo que dibuja él: barras de desplazamiento, controles de formulario, el color por defecto de un <input>. Sin esa línea, en modo oscuro verías barras de desplazamiento claras.
La línea @custom-variant dark la explicamos en el apartado 8, al hablar del modo oscuro. Tienes la teoría de las variantes en el módulo 12 del Curso de Tailwind.
4.7. @apply y @reference dentro de un módulo
A veces quieres escribir un CSS Module, pero reutilizar los valores de Tailwind (sus espaciados, sus colores). Tailwind 4 lo permite con @apply, con una condición: el fichero tiene que conocer tu tema. Eso se hace con @reference:
@reference "../globals.css";
.nombre {
@apply text-lg font-semibold;
}
@reference importa el tema y las variantes sin emitir CSS. Si usaras @import "../globals.css", cada módulo incluiría una copia entera de Tailwind. Si no pones ninguna de las dos, @apply text-lg falla porque el módulo no sabe qué es text-lg.
Fíjate en cómo queda la regla compilada (salida real de la compilación con Next.js 15.5 y PostCSS, más legible):
.TarjetaProducto-module__KxBoHG__aviso {
border-radius: var(--radius-md, .375rem);
background-color: var(--color-amber-100, #fef3c6);
padding-inline: calc(var(--spacing, .25rem) * 2);
padding-block: calc(var(--spacing, .25rem) * .5);
font-size: var(--text-xs, .75rem);
...
}
Tailwind escribe var(--color-amber-100, #fef3c6): usa tu variable de tema si existe y, si no, el valor por defecto. Por eso @apply sigue funcionando aunque el módulo se cargue antes que globals.css.
💡 Consejo: No conviertas cada componente en un módulo lleno de
@apply. Es volver al CSS tradicional con más pasos. Usa@applycuando un módulo ya existe por otro motivo (un selector complejo, una animación,:global) y quieres que sus valores sigan el sistema de diseño.
5. Sass
5.1. Instalación y uso
Next soporta Sass sin configuración, pero no trae el compilador: tienes que instalarlo tú.
npm install -D sass
Sin el paquete, el build falla con este mensaje real:
Error: Turbopack build failed with 1 error:
./app/sass/pedidos.module.scss
Error: Error evaluating Node.js code
To use Next.js' built-in Sass support, you first need to install `sass`.
Run `npm i sass` or `yarn add sass` inside your workspace.
Una vez instalado, puedes usar:
.scssy.sassglobales, importados en el layout raíz como cualquier CSS global..module.scssy.module.sass, con las mismas reglas que un CSS Module.
.scss es la sintaxis con llaves, un superconjunto de CSS: todo CSS válido es SCSS válido. .sass es la sintaxis por sangría, sin llaves ni puntos y coma. Si no tienes un motivo, usa .scss.
5.2. Un módulo Sass para los pedidos
La página /sass del laboratorio muestra los pedidos con un color por estado. El color de cada estado vive en un mapa de Sass, y un bucle genera una clase para cada uno:
// app/sass/_tokens.scss
// Parcial: el guion bajo indica que no se compila por sí solo
$espacio: 0.5rem;
$estados: (
"pendiente": #f59e0b,
"enviado": #3b82f6,
"entregado": #10b981,
"cancelado": #ef4444,
);
@mixin tarjeta($relleno: 2, $radio: 8px) {
background: var(--superficie);
border: 1px solid var(--borde);
border-radius: $radio;
padding: $espacio * $relleno;
}
// app/sass/pedidos.module.scss
@use "sass:map";
@use "tokens" as *;
.lista {
display: grid;
gap: $espacio * 2;
}
.pedido {
// $radio-base no está en este fichero: lo antepone sassOptions.additionalData
@include tarjeta(3, $radio-base);
display: flex;
justify-content: space-between;
align-items: center;
// Anidamiento: genera .pedido strong
strong {
font-family: var(--font-codigo);
}
&:hover {
border-color: var(--marca);
}
}
.estado {
border-radius: 999px;
padding: 0.125rem 0.75rem;
font-size: 0.8rem;
color: white;
}
// Un bucle crea una clase por estado: .estado-pendiente, .estado-enviado...
@each $nombre, $color in $estados {
.estado-#{$nombre} {
background: $color;
}
}
// Pasa valores de Sass a CSS como variables: así también los ve JavaScript
.lista {
--color-entregado: #{map.get($estados, "entregado")};
}
// app/sass/page.tsx
import estilos from './pedidos.module.scss'
type Estado = 'pendiente' | 'enviado' | 'entregado' | 'cancelado'
const pedidos: { pedidoId: number; cliente: string; estado: Estado }[] = [
{ pedidoId: 5001, cliente: 'Distribuciones Norte', estado: 'entregado' },
{ pedidoId: 5002, cliente: 'Papelería Central', estado: 'enviado' },
{ pedidoId: 5003, cliente: 'Talleres Ruiz', estado: 'pendiente' },
{ pedidoId: 5004, cliente: 'Clínica Dental Sur', estado: 'cancelado' },
]
export default function PaginaSass() {
return (
<main className="mx-auto max-w-3xl px-6 py-8">
<h1 className="mb-4 text-3xl font-bold">Pedidos (Sass)</h1>
<ul className={estilos.lista}>
{pedidos.map((p) => (
<li key={p.pedidoId} className={estilos.pedido}>
<span>
<strong>#{p.pedidoId}</strong> {p.cliente}
</span>
<span className={`${estilos.estado} ${estilos[`estado-${p.estado}`]}`}>{p.estado}</span>
</li>
))}
</ul>
<p className="mt-6 text-sm text-texto-suave">
Clases que exporta el módulo: {Object.keys(estilos).join(', ')}
</p>
</main>
)
}

Figura 4. La página /sass: un bucle de Sass ha generado una clase por estado.
La última línea de la página confirma qué clases contiene el objeto. Salida real:
Clases que exporta el módulo: estado, estado-cancelado, estado-entregado, estado-enviado, estado-pendiente, lista, pedido
Ahí ves lo que hizo Sass al compilar: las cuatro clases estado-* no estaban escritas en el fichero, las generó el @each. Y _tokens.scss no aporta clases propias, solo variables y un mixin, que desaparecen tras compilar.
5.3. sassOptions y additionalData
Para tener variables disponibles en todos los ficheros Sass sin importarlas a mano, next.config.ts acepta sassOptions:
const nextConfig: NextConfig = {
sassOptions: {
// Se antepone a cada fichero .scss y .sass
additionalData: `$radio-base: 12px;`,
},
}
additionalData es texto que se pega al principio de cada fichero que Next compila. Por eso pedidos.module.scss puede usar $radio-base sin declararla.
⚠️ Importante:
additionalDatase añade a los ficheros que Next compila directamente (los que importas desde un componente), no a los parciales que cargas con@use. Cada módulo de Sass tiene su propio ámbito, así que si_tokens.scssusara$radio-base, fallaría con “Undefined variable”. Por eso el mixintarjetarecibe el radio como parámetro. Si un parcial necesita un valor, pásaselo como argumento o configúralo con@use "tokens" with ($radio: 12px).
sassOptions también acepta implementation para elegir el compilador, por ejemplo 'sass-embedded' (otra distribución del mismo Dart Sass, más rápida en proyectos grandes).
5.4. Importar Sass de node_modules 🔄 16.0
🔄 Cambia en Next.js 16.0. Turbopack pasa a ser el bundler por defecto, y no admite el prefijo
~para importar denode_modules(@import '~bootstrap/...') que aceptaba webpack. Si usas Next.js 15: con webpack (next devsin--turbopack) el~sigue funcionando; con--turbopack, que es lo que trae la plantilla de 15.5, tienes el mismo límite que en 16.
En Turbopack quita el ~:
// Webpack (antiguo)
@import '~bootstrap/dist/css/bootstrap.min.css';
// Turbopack
@import 'bootstrap/dist/css/bootstrap.min.css';
Si tienes muchos ficheros heredados, la guía de actualización a la 16 propone un alias en lugar de editarlos uno a uno:
const nextConfig: NextConfig = {
turbopack: {
resolveAlias: {
'~*': '*',
},
},
}
Next 16 también actualiza sass-loader a la versión 16, que usa la API moderna de Sass. Si tu proyecto aún usa @import de Sass para tus propios parciales (no para CSS de paquetes), verás avisos de obsolescencia del propio Sass: migra a @use y @forward.
5.5. :export no funciona con Turbopack
La guía de Sass de Next enseña a exportar variables de Sass a JavaScript con un bloque :export:
:export {
colorEntregado: map.get($estados, "entregado");
}
Lo hemos probado y con Turbopack no funciona. El build avisa:
./app/sass/pedidos.module.scss.module.css:1:504
Warning: Parsing CSS source code failed
> 1 | ...0b981}.estado-cancelado{background:#ef4444}:export{colorEntregado:#10b981;radioBase:12px}
| ^
'export' is not recognized as a valid pseudo-class. Did you mean '::export' (pseudo-element) or is this a typo?
y en la página estilos.colorEntregado vale undefined. Pasa con y sin Tailwind. Con next build --webpack, en cambio, la misma página muestra colorEntregado = #10b981: :export es una extensión de css-loader, el cargador de webpack, y Turbopack no la implementa.
La alternativa que funciona con cualquier bundler es la del final de pedidos.module.scss: convertir el valor en una variable de CSS. Desde JavaScript la lees con getComputedStyle(elemento).getPropertyValue('--color-entregado'), y desde el CSS, con var(). Y si un valor lo necesitan tanto Sass como TypeScript, a menudo lo más simple es que viva en un fichero .ts y se pase al CSS con style.
6. CSS-in-JS
Las bibliotecas de CSS-in-JS (styled-components, Emotion, MUI, Chakra…) generan el CSS mientras se renderiza, en JavaScript. Eso choca con los Server Components por dos motivos:
- Un Server Component no tiene estado ni contexto de React en el cliente, y muchas de estas bibliotecas dependen de un ThemeProvider basado en contexto.
- Con streaming, el HTML llega por partes. La biblioteca tiene que recoger las reglas que se generan en cada parte e insertarlas antes del HTML que las usa, o verías contenido sin estilo un instante.
Por eso funcionan, si la biblioteca lo soporta, solo en Client Components, y necesitan un “registro de estilos” que use el hook useServerInsertedHTML de next/navigation. La documentación de Next mantiene una lista de bibliotecas compatibles (ant-design, @mui/material, styled-components, pandacss, stylex, vanilla-extract, entre otras) y la guía de cada una.
Hay un caso especial: styled-jsx viene incluido en Next, sin instalar nada. También es solo para Client Components:
// app/modulos/AvisoJsx.tsx
'use client'
// styled-jsx viene incluido en Next, pero solo funciona en Client Components
export function AvisoJsx({ texto }: { texto: string }) {
return (
<p className="aviso">
{texto}
<style jsx>{`
.aviso {
border-left: 4px solid var(--marca);
padding: 0.5rem 0.75rem;
background: var(--superficie);
}
`}</style>
</p>
)
}
HTML real: styled-jsx añade su propio prefijo, jsx- y un hash, para aislar la clase.
<p class="jsx-fdfeaecd082e8642 aviso">Este aviso usa styled-jsx dentro de un Client Component.</p>
💡 Consejo: Si empiezas un proyecto nuevo, no elijas CSS-in-JS con runtime: obliga a convertir en Client Components piezas que podrían ser de servidor, y añade JavaScript al navegador. Las bibliotecas “de cero runtime” (vanilla-extract, Panda, StyleX) generan CSS al compilar y no tienen ese problema. Y Tailwind con CSS Modules cubre casi todo.
7. Fuentes con next/font
7.1. El problema que resuelve
Cargar una fuente de Google “a la antigua” significa poner un <link> a fonts.googleapis.com. Eso tiene tres costes:
- Privacidad. El navegador de cada visitante pide la fuente a Google, que ve su IP. En la Unión Europea esto ha dado problemas legales a más de una web.
- Rendimiento. Hay que abrir conexión con otro dominio, descargar un CSS y después la fuente.
- Saltos de diseño. Mientras la fuente no ha llegado, el navegador pinta el texto con otra. Cuando llega, las letras cambian de anchura y todo se mueve. Eso es Cumulative Layout Shift (CLS), una de las Core Web Vitals.
next/font resuelve los tres:
- Descarga la fuente al compilar y la sirve desde tu propio dominio (
/_next/static/media/...woff2). El navegador del usuario nunca habla con Google. Consecuencia:next buildnecesita conexión a internet si usasnext/font/google. - Precarga los ficheros que usa cada ruta.
- Calcula una fuente de reserva ajustada (con
size-adjusty similares) para que el texto ocupe lo mismo antes y después de que llegue la fuente real.
7.2. Fuentes de Google
// app/layout.tsx
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
Cada fuente de Google es una función que se importa por su nombre (los espacios pasan a guiones bajos: Roboto_Mono, Playfair_Display). La llamada se hace en el ámbito del módulo y se asigna a una constante, nunca dentro de un componente: Next la analiza al compilar para descargar la fuente.
Las opciones más útiles:
| Opción | Para qué sirve |
|---|---|
subsets | Qué subconjuntos de caracteres precargar ('latin' cubre el español, incluidas ñ y tildes) |
weight | Grosores. Obligatorio si la fuente no es variable; con fuentes variables no hace falta |
style | 'normal', 'italic' o los dos en un array |
display | Valor de font-display; por defecto 'swap' |
variable | Nombre de la variable de CSS que se creará ('--font-inter') |
preload | Precargar o no; por defecto true |
fallback | Fuentes de reserva (['system-ui', 'arial']) |
adjustFontFallback | Ajustar la de reserva para evitar CLS; por defecto true |
axes | Ejes extra de una fuente variable, como 'slnt' |
💡 Consejo: Prefiere fuentes variables: un solo fichero contiene todos los grosores. Inter, Geist, Roboto Flex o Lora lo son. En Google Fonts puedes filtrar por “Variable”.
7.3. Tres formas de aplicar una fuente
El objeto que devuelve la función tiene tres propiedades. La página /fuentes del laboratorio imprime dos de ellas para la fuente Lora:
// app/fuentes/page.tsx
import { Lora } from 'next/font/google'
// Una fuente solo para esta página: se precarga solo en esta ruta
const lora = Lora({ subsets: ['latin'], style: ['normal', 'italic'] })
export default function PaginaFuentes() {
return (
<main className="mx-auto max-w-3xl space-y-6 px-6 py-8">
<h1 className="text-3xl font-bold">Fuentes con next/font</h1>
<p className="text-xl">Inter (variable --font-inter, utilidad font-sans): Ñandú ágil 0123456789</p>
<p className="font-codigo text-xl">GeistMono local (--font-codigo): Ñandú ágil 0123456789</p>
<p className={`${lora.className} text-xl italic`}>Lora con className: Ñandú ágil 0123456789</p>
<pre className="rounded-lg border border-borde bg-superficie p-4 font-codigo text-sm">
{JSON.stringify({ className: lora.className, style: lora.style }, null, 2)}
</pre>
</main>
)
}
Salida real:
{
"className": "ABGmDq_className",
"style": {
"fontFamily": "'Lora', 'Lora Fallback'"
}
}

Figura 5. Tres fuentes cargadas con next/font, cada una aplicada de una forma distinta.
className: una clase que aplicafont-family. Lo más directo para un elemento concreto.style: un objeto para el atributostyle. Fíjate en'Lora Fallback': es la fuente de reserva ajustada que Next ha creado.variable: una clase que define la variable de CSS (--font-inter), sin aplicarla. Es la forma de conectar con Tailwind.
7.4. Con Tailwind: la variable en <html> y la utilidad en @theme
El patrón de la plantilla, y el del laboratorio, es:
- Poner las clases
variableen<html>, para que las variables existan en todo el documento. - Mapearlas en
@theme inlinea--font-sanso a un nombre propio.
<html lang="es" className={`${inter.variable} ${codigo.variable} antialiased`}>
@theme inline {
--font-sans: var(--font-inter);
--font-codigo: var(--font-codigo);
}
Así, font-sans (que Tailwind aplica por defecto al documento) usa Inter, y font-codigo es una utilidad nueva. HTML real del <html>:
<html lang="es" data-tema="claro" class="ec5Qua_variable h4S_3q_variable antialiased">
ec5Qua_variable define --font-inter; h4S_3q_variable, --font-codigo.
7.5. Fuentes locales
Para una fuente que no está en Google (la tipografía corporativa de EMPRESA, por ejemplo), usa next/font/local con el fichero dentro del proyecto:
import localFont from "next/font/local";
const codigo = localFont({
src: "./_fuentes/GeistMono.woff2",
variable: "--font-codigo",
display: "swap",
});
La ruta de src es relativa al fichero donde llamas a localFont, y el fichero puede estar en app/ (en una carpeta privada como _fuentes, que el router ignora) o en public/. Si la familia tiene varios ficheros, src acepta un array de { path, weight, style }.
next/font/local tiene opciones propias, como declarations para añadir descriptores al @font-face generado (ascent-override, por ejemplo) y un adjustFontFallback que acepta 'Arial', 'Times New Roman' o false.
7.6. Un fichero de definiciones
Si la misma fuente se usa en varios sitios, no la declares varias veces: cada llamada crea una instancia distinta. Defínela una vez en un fichero y expórtala:
// app/ejercicios/fuentes.ts
import localFont from 'next/font/local'
// Fichero de definiciones: se importa desde donde haga falta
export const titulos = localFont({
src: '../_fuentes/Geist.woff2',
variable: '--font-titulos',
weight: '100 900',
display: 'swap',
})
7.7. Qué se precarga y dónde
Una fuente se precarga solo en las rutas donde se usa: si la llamas en el layout raíz, en todas; si la llamas en una página, solo en esa. Lo hemos comprobado mirando las cabeceras HTTP de la compilación de producción. En Next.js 16.4 con Cache Components, la precarga no aparece como <link rel="preload"> en el HTML, sino como cabecera Link de la respuesta:
link: </_next/static/media/07c7676b89ed2778-s.p.2bn3s6zvc0dyp.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"
</_next/static/media/GeistMono-s.p.3f30ggp2vw8zj.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"
</_next/static/media/c3d9c74ef0d9c07b-s.p.23aeddxv5enbo.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"
</_next/static/media/f36d61f2f30b50ce-s.p.22x-ws2e_cdqh.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"
Esas son las cuatro de /fuentes: Inter y GeistMono (del layout raíz) y Lora normal y cursiva (de la página). En / la misma cabecera trae solo dos fuentes: Lora no se precarga donde no se usa. Solo se precargan los ficheros del subconjunto latin; el resto (cirílico, vietnamita…) sigue disponible con unicode-range y el navegador los descarga solo si los necesita.
⚠️ Importante: Si buscas
rel="preload"en el código fuente de la página para comprobar si tu fuente se precarga, puede que no lo encuentres. Mira la pestaña Red de las herramientas de desarrollo o la cabeceraLinkde la respuesta (curl -I).
8. Temas y modo oscuro sin parpadeo
8.1. El problema
El modo oscuro tiene una dificultad que en una SPA no existe: el servidor no sabe qué tema quiere el usuario. La preferencia está en localStorage o en la configuración del sistema operativo, y ninguna de las dos viaja con la petición. El servidor genera el HTML con un tema por defecto, normalmente claro.
Si el usuario eligió oscuro, alguien tiene que cambiarlo en el navegador. La pregunta es cuándo.
Figura 6. Lo que ve el usuario según cuándo se aplica el tema.
8.2. La versión ingenua: useEffect
El primer intento suele ser leer la preferencia en un efecto:
// app/parpadeo/PanelIngenuo.tsx
'use client'
import { useEffect, useState } from 'react'
// Versión ingenua: lee la preferencia en un efecto, después de pintar
export function PanelIngenuo() {
const [oscuro, setOscuro] = useState(false)
useEffect(() => {
setOscuro(localStorage.getItem('tema') === 'oscuro')
}, [])
return (
<div
className={`rounded-lg border p-6 ${oscuro ? 'border-slate-600 bg-slate-800 text-slate-100' : 'border-slate-300 bg-white text-slate-900'}`}
>
<p className="font-semibold">Panel con useEffect</p>
<p className="text-sm">Hasta que React se hidrata y ejecuta el efecto, se ve claro.</p>
</div>
)
}
No da errores de hidratación: el primer render del cliente coincide con el del servidor (los dos con oscuro = false). Pero los efectos se ejecutan después de pintar, y solo cuando React ya se ha descargado y se ha hidratado. Hasta entonces, el usuario ve el tema claro. En una conexión lenta pueden ser segundos: un fogonazo blanco en mitad de la noche.
¿Y leer localStorage directamente en el render, sin efecto? En el servidor no existe localStorage, así que fallaría allí; y si lo proteges con typeof window !== 'undefined', el servidor y el cliente producen HTML distinto y tienes un error de hidratación. Lo viste en el módulo 05.
8.3. La solución: un script en el <head>
El navegador ejecuta un <script> normal (sin async, defer ni type="module") en el momento de leerlo, antes de seguir analizando el HTML y antes de pintar nada. Si ese script está en el <head> y pone el atributo del tema en <html>, cuando llegue el primer pintado el tema ya es el correcto. No espera a React ni a ninguna descarga.
// app/_componentes/script-tema.ts
// Se ejecuta en el navegador mientras se lee el <head>, antes de pintar nada.
// Por eso va como texto y en JavaScript antiguo: no pasa por el bundler.
export const SCRIPT_TEMA = `(function () {
try {
var t = localStorage.getItem('tema');
if (t !== 'claro' && t !== 'oscuro') {
t = matchMedia('(prefers-color-scheme: dark)').matches ? 'oscuro' : 'claro';
}
document.documentElement.dataset.tema = t;
} catch (e) {}
})();`;
// app/layout.tsx
import type { Metadata } from "next";
import Link from "next/link";
import { Inter } from "next/font/google";
import localFont from "next/font/local";
import { SelectorTema } from "./_componentes/SelectorTema";
import { SCRIPT_TEMA } from "./_componentes/script-tema";
import "./globals.css";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
const codigo = localFont({
src: "./_fuentes/GeistMono.woff2",
variable: "--font-codigo",
display: "swap",
});
export const metadata: Metadata = {
title: "EMPRESA · Tienda",
description: "Catálogo de la tienda EMPRESA",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="es"
data-tema="claro"
suppressHydrationWarning
className={`${inter.variable} ${codigo.variable} antialiased`}
>
<head>
<script dangerouslySetInnerHTML={{ __html: SCRIPT_TEMA }} />
</head>
<body className="min-h-screen font-sans">
<header className="flex items-center gap-6 border-b border-borde bg-superficie px-6 py-3">
<Link href="/" className="font-bold text-marca">
EMPRESA
</Link>
<nav className="flex gap-4 text-sm text-texto-suave">
<Link href="/modulos">CSS Modules</Link>
<Link href="/sass">Sass</Link>
<Link href="/fuentes">Fuentes</Link>
<Link href="/parpadeo">Parpadeo</Link>
</nav>
<div className="ml-auto">
<SelectorTema />
</div>
</header>
{children}
</body>
</html>
)
}
Detalle a detalle:
data-tema="claro"es el valor del servidor. El HTML es igual para todos los usuarios, así que la página se puede prerenderizar y servir desde caché.- El script lo sustituye antes de pintar. Si no hay preferencia guardada, usa la del sistema operativo con
matchMedia. try/catch:localStoragelanza una excepción en algunos navegadores con la privacidad muy restringida. Si falla, se queda el valor por defecto y la página sigue funcionando.- JavaScript antiguo (
var, función autoejecutable): el texto del script no pasa por el bundler, así que no se transpila. Escríbelo para que funcione en cualquier navegador. suppressHydrationWarningen<html>: React compara el DOM con lo que él habría generado. El atributodata-temaya no coincide (oscurofrente aclaro) porque lo ha cambiado el script. Este atributo le dice a React que acepte lo que hay en el DOM en ese elemento. Solo afecta a los atributos de<html>, no a sus hijos.dangerouslySetInnerHTML: es la forma de escribir el contenido de un<script>en JSX. El nombre asusta a propósito; aquí es seguro porque el texto es una constante nuestra, no un dato del usuario.
Fíjate en el HTML real: React mueve las hojas de estilo al principio del <head>, y el script queda después. Las hojas de estilo bloquean el pintado, así que el script se ejecuta igualmente antes del primer pintado.
<head><meta charSet="utf-8"/>...<link rel="stylesheet" href="/_next/static/chunks/2-az5j914hpnm.css" data-precedence="next"/>...<script>(function () {
try {
var t = localStorage.getItem('tema');
...
})();</script>...</head>
8.4. La prueba: sin JavaScript de React
Para demostrar que el script no depende de React, el laboratorio tiene una página /parpadeo con dos paneles: uno usa las variables del tema (bg-superficie) y otro es el PanelIngenuo. Con Playwright guardamos tema = 'oscuro' en localStorage y bloqueamos todos los ficheros JavaScript de Next (/_next/static/chunks/*.js). Así vemos exactamente lo que ve el usuario antes de la hidratación:
await contexto.addInitScript((t) => localStorage.setItem('tema', t), 'oscuro')
await pagina.route('**/_next/static/chunks/*.js', (r) => r.abort())

Figura 7. Sin el JavaScript de React: el script del <head> ya ha aplicado el tema oscuro; el panel con useEffect sigue en claro.

Figura 8. Con JavaScript: el efecto por fin se ejecuta y el panel ingenuo cambia. Ese salto es el parpadeo.
Y la comprobación por consola del mismo script de Playwright, con el JavaScript bloqueado:
06-parpadeo-sin-js.png data-tema = oscuro
8.5. La variante dark: de Tailwind con un atributo
Por defecto, dark: de Tailwind reacciona a prefers-color-scheme, la preferencia del sistema. Nosotros queremos que reaccione a nuestro atributo, que tiene en cuenta la elección del usuario. Eso es lo que hace esta línea de globals.css:
@custom-variant dark (&:where([data-tema="oscuro"], [data-tema="oscuro"] *));
Se lee así: “un elemento está en modo oscuro si él mismo, o alguno de sus antepasados, tiene data-tema="oscuro"”. El :where() hace que la especificidad sea cero, para que dark:bg-x no gane a otras clases por accidente. La página /parpadeo usa dark:shadow-lg dark:shadow-black/40 en el primer panel, y en la figura 7 ves que la sombra está aplicada sin JavaScript.
8.6. El selector de tema
El selector ofrece tres opciones: claro, oscuro y sistema. “Sistema” significa “no guardes nada y sigue al sistema operativo, incluso si cambia mientras la página está abierta”.
// app/_componentes/SelectorTema.tsx
'use client'
import { useEffect, useLayoutEffect, useSyncExternalStore } from 'react'
type Preferencia = 'claro' | 'oscuro' | 'sistema'
const CLAVE = 'tema'
const EVENTO = 'cambio-tema'
function leerPreferencia(): Preferencia {
const valor = localStorage.getItem(CLAVE)
return valor === 'claro' || valor === 'oscuro' ? valor : 'sistema'
}
function temaEfectivo(preferencia: Preferencia): 'claro' | 'oscuro' {
if (preferencia !== 'sistema') return preferencia
return matchMedia('(prefers-color-scheme: dark)').matches ? 'oscuro' : 'claro'
}
function aplicar(preferencia: Preferencia) {
document.documentElement.dataset.tema = temaEfectivo(preferencia)
}
function suscribir(avisar: () => void) {
window.addEventListener('storage', avisar) // cambios desde otra pestaña
window.addEventListener(EVENTO, avisar) // cambios desde esta pestaña
return () => {
window.removeEventListener('storage', avisar)
window.removeEventListener(EVENTO, avisar)
}
}
const OPCIONES: { valor: Preferencia; texto: string }[] = [
{ valor: 'claro', texto: 'Claro' },
{ valor: 'oscuro', texto: 'Oscuro' },
{ valor: 'sistema', texto: 'Sistema' },
]
export function SelectorTema() {
// En el servidor y durante la hidratación vale null: no hay localStorage
const preferencia = useSyncExternalStore(suscribir, leerPreferencia, () => null)
// En desarrollo, Strict Mode vuelve a montar <html> y borra data-tema: lo reponemos
useLayoutEffect(() => {
aplicar(leerPreferencia())
}, [])
// Con "sistema", seguimos los cambios del sistema operativo en vivo
useEffect(() => {
if (preferencia !== 'sistema') return
const consulta = matchMedia('(prefers-color-scheme: dark)')
const alCambiar = () => aplicar('sistema')
consulta.addEventListener('change', alCambiar)
return () => consulta.removeEventListener('change', alCambiar)
}, [preferencia])
function elegir(valor: Preferencia) {
if (valor === 'sistema') localStorage.removeItem(CLAVE)
else localStorage.setItem(CLAVE, valor)
aplicar(valor)
window.dispatchEvent(new Event(EVENTO))
}
return (
<div role="group" aria-label="Tema" className="flex rounded-lg border border-borde p-0.5 text-sm">
{OPCIONES.map((opcion) => (
<button
key={opcion.valor}
type="button"
aria-pressed={preferencia === opcion.valor}
onClick={() => elegir(opcion.valor)}
className="rounded-md px-3 py-1 text-texto-suave aria-pressed:bg-marca aria-pressed:text-white dark:aria-pressed:text-slate-900"
>
{opcion.texto}
</button>
))}
</div>
)
}
Las piezas clave:
useSyncExternalStorees el hook de React para leer un valor que vive fuera de React (aquí,localStorage). Recibe tres funciones: cómo suscribirse a cambios, cómo leer el valor en el cliente y cómo leerlo en el servidor. Esta última devuelvenull, y React la usa también durante la hidratación. Así el primer render del cliente coincide con el del servidor (ningún botón marcado) y, justo después, React vuelve a renderizar con el valor real. Sin errores de hidratación y sin efectos consetState.- El evento
storagesolo salta en las otras pestañas cuando cambialocalStorage. Para avisar a la pestaña actual disparamos un evento propio,cambio-tema. Resultado: si cambias el tema en una pestaña, las demás se actualizan solas. aria-pressedcomunica el estado a los lectores de pantalla, y la variantearia-pressed:de Tailwind lo usa para el estilo. Un solo atributo sirve para accesibilidad y diseño.- El efecto de
matchMediasolo se activa con “sistema”: si cambias el sistema operativo a modo oscuro con la página abierta, la página cambia también.
Comprobación real con Playwright (sistema en claro, clic en “Oscuro”, recarga, clic en “Sistema”):
clic Oscuro -> [ 'oscuro', 'oscuro' ] | tras recargar: oscuro | aria-pressed: true
clic Sistema -> [ 'claro', null ]
Tras la recarga, el tema sigue siendo oscuro y el botón aparece marcado. Al elegir “Sistema” se borra la clave y se vuelve al tema del sistema, que en la prueba era claro.

Figura 9. El catálogo con el sistema en modo claro.

Figura 10. El mismo catálogo con el sistema en modo oscuro, sin tocar ningún componente: solo cambian las variables.
📘 Concepto: La documentación de Next advierte de que, en desarrollo, Strict Mode vuelve a montar los componentes una vez y al hacerlo restablece los atributos de
<html>que React gestiona, borrando el que puso el script. Por eso el selector repone el tema en unuseLayoutEffect, que se ejecuta antes de pintar. En nuestras pruebas con Next.js 16.4 ennext devel atributo se conservaba también sin ese efecto (sin errores ni avisos en consola en ninguno de los dos casos), pero lo dejamos: es barato, no tiene efecto en producción y te protege si el comportamiento cambia.
8.7. ¿Y una cookie leída en el servidor?
Otra opción es guardar el tema en una cookie y leerla en el layout raíz con cookies(). El servidor generaría directamente data-tema="oscuro" y no haría falta ningún script. Suena mejor, pero tiene un precio alto:
cookies()es una API de petición. Usarla en el layout raíz hace que todo lo que cuelga de él dependa de cada petición: ya no hay páginas estáticas ni HTML en caché.- Con Cache Components (activado en la plantilla de 16.4), la documentación advierte de que leer la cookie en el layout raíz obliga a esperar a la petición en todos los segmentos que cuelgan de él: ninguna página puede servir su armazón estático al instante. Y el
<html>no se puede envolver en<Suspense>para aislar esa espera.
Lo verás en detalle en el módulo 09. La conclusión para los temas: guarda la preferencia donde quieras (localStorage o cookie), pero léela en el script del navegador, no en el servidor. Si prefieres cookie (por ejemplo, para que el servidor la tenga disponible en otras rutas dinámicas), el script la lee de document.cookie; lo tienes en el ejercicio 7.
⚠️ Importante: Si tu aplicación usa una Content Security Policy estricta, los scripts en línea están bloqueados salvo que lleven un nonce. El script del tema necesitará ese nonce. Lo verás en el módulo 22.
💡 Consejo: La biblioteca
next-themesempaqueta esta misma técnica (script en el<head>, atributo en<html>, sincronización entre pestañas) y es muy popular. Ahora que sabes lo que hace por dentro, puedes usarla con criterio o escribir tu versión en cincuenta líneas como la de este apartado.
9. Caso práctico: el catálogo de la tienda EMPRESA
Juntamos todo en la página de inicio del laboratorio: el catálogo público de la tienda. Los datos son una copia de seis productos de la API EMPRESA; a partir del módulo 08 los pediremos a la API de verdad.
9.1. Estructura
app/
├── _componentes/
│ ├── base.module.css ← estilos compartidos (.caja)
│ ├── script-tema.ts ← script del <head>
│ ├── SelectorTema.tsx ← Client Component
│ ├── TarjetaProducto.module.css
│ └── TarjetaProducto.tsx ← Server Component
├── _fuentes/
│ └── GeistMono.woff2 ← fuente local
├── lib/
│ ├── clases.ts ← unirClases()
│ └── productos.ts
├── globals.css ← Tailwind, tokens y tema
├── layout.tsx ← fuentes, script y cabecera
└── page.tsx ← catálogo
Las carpetas con guion bajo (_componentes, _fuentes) son carpetas privadas: el router las ignora y nunca generan rutas. Lo viste en el módulo 04.
9.2. Los datos
// app/lib/productos.ts
export type Producto = {
productoId: number
nombre: string
descripcion: string
precio: number
stock: number
}
// Copia de algunos productos de la API EMPRESA. Los datos reales llegan en el módulo 08.
export const productos: Producto[] = [
{ productoId: 1, nombre: 'Portátil UltraBook 14', descripcion: 'Portátil ligero de 14 pulgadas, 16 GB RAM', precio: 899, stock: 25 },
{ productoId: 2, nombre: 'Portátil Gaming 16', descripcion: 'Portátil de 16 pulgadas con gráfica dedicada', precio: 1499, stock: 10 },
{ productoId: 3, nombre: 'Portátil Básico 15', descripcion: 'Portátil de 15 pulgadas para ofimática', precio: 499, stock: 40 },
{ productoId: 4, nombre: 'Torre Oficina i5', descripcion: 'Ordenador de sobremesa para oficina', precio: 649, stock: 15 },
{ productoId: 5, nombre: 'Torre Workstation', descripcion: 'Estación de trabajo para diseño y CAD', precio: 1899, stock: 4 },
{ productoId: 6, nombre: 'Ratón inalámbrico', descripcion: 'Ratón óptico inalámbrico', precio: 19.9, stock: 200 },
]
export const formatoPrecio = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' })
9.3. La página
// app/page.tsx
import { TarjetaProducto } from './_componentes/TarjetaProducto'
import { productos } from './lib/productos'
export default function Catalogo() {
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<h1 className="mb-1 text-3xl font-bold tracking-tight">Catálogo</h1>
<p className="mb-6 text-texto-suave">
{productos.length} productos · precios con IVA
</p>
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{productos.map((p) => (
<TarjetaProducto key={p.productoId} producto={p} />
))}
</section>
</main>
)
}
El reparto de responsabilidades es el que recomendamos para todo el curso:
| Pieza | Técnica | Por qué |
|---|---|---|
| Colores, fuentes y tema | Variables en globals.css + @theme inline | Una sola fuente de verdad; el modo oscuro no toca componentes |
| Maquetación de la página (rejilla, márgenes, títulos) | Utilidades de Tailwind | Rápido, se lee en el propio JSX, sin nombres que inventar |
| Tarjeta de producto | CSS Module con composes, atributo data- y :global | Estilo propio con estados y transición, aislado del resto |
| Precio | Fuente local por variable | Cifras de anchura fija, alineadas en columna |
| Selector de tema | Client Component | Necesita eventos y localStorage; es la única pieza que llega como JavaScript |
Todo el catálogo, salvo el selector de tema, son Server Components. Y la página entera se prerenderiza en el build: el script del tema permite que el HTML sea el mismo para todos.
9.4. Verificación en producción
npm run build
npm run start -- -p 3001
Recuerda que el puerto 3000 lo usa la API EMPRESA; arranca Next en otro, como el 3001. En el navegador:
- Abre las herramientas de desarrollo, pestaña Elementos, y comprueba que
<html>tienedata-temay las clases..._variablede las fuentes. - Pulsa “Oscuro”, recarga con la caché desactivada y una conexión lenta simulada (pestaña Red, “3G lenta”). No debe haber fogonazo blanco.
- En Red, filtra por “Font”: solo deben descargarse ficheros
.woff2de/_next/static/media/, ninguno de Google.
10. Orden, agrupación y carga del CSS en producción
10.1. El orden depende del orden de los import
Cuando dos reglas tienen la misma especificidad, gana la que aparece después. En una aplicación con muchos ficheros CSS, “después” depende de el orden en que se importan. Next sigue el orden de los import desde el punto de entrada: si una página importa primero <BotonBase> (que importa boton-base.module.css) y luego pagina.module.css, este último va detrás y gana.
Recomendaciones de la documentación que conviene seguir:
- Importa los CSS globales y la entrada de Tailwind solo en el layout raíz.
- Usa Tailwind para la mayoría de estilos y CSS Modules para lo específico.
- Extrae los estilos compartidos a componentes compartidos, en lugar de importar el mismo CSS desde muchos sitios.
- Desactiva las reglas de linter o formateador que reordenan los imports automáticamente (como
sort-importsde ESLint): pueden cambiar el orden del CSS sin que lo notes.
⚠️ Importante: En desarrollo el orden del CSS puede ser distinto al de producción. Si algo se ve bien en
next devy mal trasnext build, el primer sospechoso es el orden. Comprueba siempre el aspecto final connext build && next start.
10.2. cssChunking
experimental.cssChunking controla cómo se agrupan los CSS en ficheros:
| Valor | Bundler | Efecto |
|---|---|---|
true (por defecto) | webpack y Turbopack | Fusiona CSS siempre que puede para hacer menos peticiones |
false | solo webpack | No fusiona ni reordena |
'strict' | solo webpack | Respeta el orden exacto de los imports, aunque genere más ficheros |
'graph' | solo Turbopack | Algoritmo de coste que equilibra bytes y peticiones entre rutas |
10.3. El modo 'graph' 🆕 16.3
🆕 Desde Next.js 16.3. No existe en Next.js 15. Si usas Next.js 15: quédate con el valor por defecto (
true), o usa'strict'con webpack si tienes problemas de orden; no hay forma de ajustar el reparto entre bytes y peticiones.
const nextConfig: NextConfig = {
experimental: {
cssChunking: 'graph',
// o con ajustes:
// cssChunking: { type: 'graph', requestCost: 20000, weightDistribution: 0.1 },
},
}
La idea: cada ruta necesita una lista de ficheros CSS. Meterlos todos en un fichero compartido ahorra peticiones, pero obliga a algunas rutas a descargar CSS que no usan. requestCost es “cuántos bytes de CSS innecesario vale la pena descargar para ahorrarse una petición” (por defecto unos 20 KB). Súbelo y tendrás menos ficheros más grandes; bájalo y cada ruta descargará casi solo lo suyo, con más peticiones. La mayoría de proyectos no necesitan tocarlo. Hemos comprobado en los tipos publicados que el valor 'graph' aparece por primera vez en next@16.3.0.
10.4. inlineCss (experimental)
experimental.inlineCss: true sustituye los <link rel="stylesheet"> por bloques <style> dentro del HTML. Elimina una ida y vuelta a la red en la primera visita, lo que mejora FCP y LCP, y encaja bien con Tailwind porque su CSS es pequeño. A cambio, el CSS deja de estar en caché aparte y se descarga de nuevo con cada página. Solo funciona en compilaciones de producción y se aplica a toda la aplicación. Úsalo si la mayoría de tus visitas son nuevas (una web de campaña, por ejemplo), no en un panel de gestión donde la gente vuelve cada día.
11. Errores frecuentes
| Síntoma | Causa | Solución |
|---|---|---|
import estilos from './x.module.css' devuelve {} y los elementos salen sin class | La regla "*.css" de Tailwind de la plantilla de 16.4 convierte los módulos en CSS normal | Regla separada para módulos (apartado 4.3) o PostCSS (apartado 4.4) |
Aviso 'global' is not recognized as a valid pseudo-class en un fichero .module.css.css | El mismo problema: el módulo se procesa como CSS global | Igual que el anterior |
Un estilo de módulo no se aplica y el elemento no tiene class | Clave mal escrita: estilos.tarjta vale undefined y TypeScript no avisa | Revisa el nombre; imprime Object.keys(estilos) |
| Una clase de Tailwind no tiene efecto | La clase se construye con plantillas (`bg-${color}-500`) | Escribe las clases completas en un objeto o usa variables de CSS |
@apply falla en un módulo porque no reconoce la utilidad | El módulo no conoce el tema | Añade @reference "../globals.css"; |
| Estilos de una sección “se cuelan” en otra tras navegar | Un CSS global importado en un layout anidado | Solo globales en el layout raíz; lo demás, módulos o Tailwind |
To use Next.js' built-in Sass support, you first need to install sass | Falta el paquete | npm install -D sass |
Undefined variable en un parcial de Sass | additionalData no se aplica a los parciales cargados con @use | Pasa el valor por parámetro o con @use ... with (...) |
'export' is not recognized as a valid pseudo-class y el valor es undefined | :export no lo implementa Turbopack | Exporta como variable de CSS |
@import '~paquete/...' no se resuelve | Turbopack no admite ~ | Quita el ~ o usa turbopack.resolveAlias |
| Fogonazo de tema claro al cargar | El tema se aplica en un useEffect | Script en el <head> + suppressHydrationWarning en <html> |
Error de hidratación al leer localStorage en el render | Servidor y cliente generan HTML distinto | useSyncExternalStore con getServerSnapshot, o el script |
| La fuente de la plantilla no se ve (sale Arial) | El body de globals.css fuerza font-family: Arial | Usa var(--font-sans) o la clase font-sans |
next build falla al descargar una fuente | next/font/google descarga las fuentes al compilar y no hay red | Compila con conexión o usa next/font/local |
Todo bien en next dev, mal en producción | Orden del CSS distinto | Comprueba con next build && next start; revisa el orden de los imports |
Una prueba E2E deja de encontrar .Poillq_tarjeta | Los nombres generados cambian entre builds y versiones | Selecciona por rol, texto o atributo data- |
12. Ejercicios
Todos los ejercicios resueltos se han compilado en el mismo laboratorio que el resto del módulo. La página /ejercicios reúne las soluciones de los ejercicios 1, 2, 3, 4 y 8:

Figura 11. Las soluciones de los ejercicios 1, 2, 3, 4 y 8 en la página /ejercicios.
Ejercicio 1: Botón con variantes en un CSS Module
Crea un componente Boton con una propiedad variante ('primario' | 'secundario' | 'peligro', por defecto 'primario') que acepte todas las propiedades de un <button> nativo, incluida className para añadir clases desde fuera. Los estilos van en Boton.module.css. En modo oscuro, el texto del botón primario debe ser oscuro, porque el color de marca se aclara.
Ver solución
/* app/ejercicios/Boton.module.css */
.boton {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border: 1px solid transparent;
border-radius: 8px;
padding: 0.5rem 1rem;
font-weight: 600;
cursor: pointer;
}
.boton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.primario {
background: var(--marca);
color: white;
}
.secundario {
background: transparent;
border-color: var(--borde);
color: var(--texto);
}
.peligro {
background: #dc2626;
color: white;
}
:global([data-tema="oscuro"]) .primario {
color: #0f172a;
}
// app/ejercicios/Boton.tsx
import type { ComponentProps } from 'react'
import { unirClases } from '../lib/clases'
import estilos from './Boton.module.css'
type Variante = 'primario' | 'secundario' | 'peligro'
type Props = ComponentProps<'button'> & { variante?: Variante }
export function Boton({ variante = 'primario', className, ...resto }: Props) {
return (
<button
{...resto}
className={unirClases(estilos.boton, estilos[variante], className)}
/>
)
}
HTML real de los tres botones:
<button class="USdjFG_boton USdjFG_primario">
<button class="USdjFG_boton USdjFG_secundario">
<button disabled="" class="USdjFG_boton USdjFG_peligro">
Puntos clave:
ComponentProps<'button'>da todas las propiedades nativas (type,disabled,onClick,aria-*…), y...restolas pasa al elemento.- Usar el nombre de la variante como clave (
estilos[variante]) evita unswitch. Funciona porque las claves del CSS coinciden con los valores del tipo. classNameva al final deunirClases, para que quien use el botón pueda añadir sus clases.- El componente no lleva
'use client': no tiene estado ni eventos propios. Si quien lo usa le pasaonClick, ese alguien será un Client Component, y el botón se convertirá en cliente por estar importado desde él. Lo viste en el módulo 05.
Ejercicio 2: Indicador de stock con atributo de datos
Crea IndicadorStock que muestre el stock de un producto con tres aspectos: rojo si es 0 (texto “Agotado”), ámbar si es menor que 12 y verde en otro caso (texto “N uds.”). Usa una sola clase de módulo y un atributo data-nivel.
Ver solución
/* app/ejercicios/IndicadorStock.module.css */
.indicador {
display: inline-block;
border-radius: 999px;
padding: 0.125rem 0.625rem;
font-size: 0.75rem;
font-weight: 600;
}
.indicador[data-nivel="agotado"] { background: #fee2e2; color: #991b1b; }
.indicador[data-nivel="bajo"] { background: #fef3c7; color: #92400e; }
.indicador[data-nivel="normal"] { background: #dcfce7; color: #166534; }
// app/ejercicios/IndicadorStock.tsx
import estilos from './IndicadorStock.module.css'
function nivel(stock: number) {
if (stock === 0) return 'agotado'
if (stock < 12) return 'bajo'
return 'normal'
}
export function IndicadorStock({ stock }: { stock: number }) {
return (
<span className={estilos.indicador} data-nivel={nivel(stock)}>
{stock === 0 ? 'Agotado' : `${stock} uds.`}
</span>
)
}
HTML real para los stocks 0, 4 y 25:
<span class="evZ3Oq_indicador" data-nivel="agotado">
<span class="evZ3Oq_indicador" data-nivel="bajo">
<span class="evZ3Oq_indicador" data-nivel="normal">
Ventajas frente a tres clases distintas: el estado se ve en el HTML con un nombre legible (útil al depurar y en las pruebas: [data-nivel="agotado"] no cambia entre builds), y el componente no tiene que elegir entre clases. La lógica de negocio (qué es “bajo”) vive en TypeScript; el aspecto, en el CSS.
Ejercicio 3: Rejilla responsive con un mixin de Sass
Crea un parcial _rejilla.scss con un mapa de puntos de corte (movil: 0, tableta: 640px, escritorio: 1024px) y un mixin desde($nombre) que envuelva su contenido en la @media correspondiente (o en nada, para movil). Úsalo en empleados.module.scss para una rejilla de fichas de empleado de 1, 2 y 3 columnas.
Ver solución
// app/ejercicios/_rejilla.scss
@use "sass:map";
$puntos: (
"movil": 0,
"tableta": 640px,
"escritorio": 1024px,
);
@mixin desde($nombre) {
$ancho: map.get($puntos, $nombre);
@if $ancho == 0 {
@content;
} @else {
@media (min-width: $ancho) {
@content;
}
}
}
// app/ejercicios/empleados.module.scss
@use "rejilla" as *;
.rejilla {
display: grid;
gap: 1rem;
grid-template-columns: 1fr;
@include desde("tableta") {
grid-template-columns: repeat(2, 1fr);
}
@include desde("escritorio") {
grid-template-columns: repeat(3, 1fr);
}
}
.ficha {
background: var(--superficie);
border: 1px solid var(--borde);
border-radius: 10px;
padding: 1rem;
h3 {
font-weight: 600;
}
small {
color: var(--texto-suave);
}
}
// En app/ejercicios/page.tsx
import estilosEmpleados from './empleados.module.scss'
const empleados = [
{ empleadoId: 100, nombre: 'Ana García', puesto: 'Directora' },
{ empleadoId: 101, nombre: 'Luis Pérez', puesto: 'Analista' },
{ empleadoId: 102, nombre: 'Marta Ruiz', puesto: 'Comercial' },
]
<section className={estilosEmpleados.rejilla}>
{empleados.map((e) => (
<article key={e.empleadoId} className={estilosEmpleados.ficha}>
<h3>{e.nombre}</h3>
<small>{e.puesto}</small>
</article>
))}
</section>
Si escribes map-get(...) (la función global de toda la vida) en lugar de map.get(...), funciona, pero el build muestra este aviso real de Sass 1.107:
SassWarning: Deprecation Warning on line 7, column 10 of file:///.../app/ejercicios/_rejilla.scss:7:10:
Global built-in functions are deprecated and will be removed in Dart Sass 3.0.0.
Use map.get instead.
Las funciones de Sass se cargan ahora como módulos (@use "sass:map", "sass:math", "sass:color"). Es buen momento para acostumbrarse.
@content es el hueco donde Sass mete el bloque que escribes entre llaves al usar el mixin. Lo interesante es que la @media queda dentro de la regla .rejilla, junto al resto de sus estilos: Sass la saca fuera al compilar. Con Tailwind harías lo mismo con sm:grid-cols-2 lg:grid-cols-3; este ejercicio es útil si tu proyecto ya usa Sass. .ficha h3 es válido en un módulo porque el selector contiene una clase local.
Ejercicio 4: Fuente local para los títulos
Usa el fichero Geist.woff2 (una fuente variable con grosores de 100 a 900) como fuente de los títulos. Defínela en un fichero de definiciones fuentes.ts con la variable --font-titulos, aplícala solo en la página /ejercicios y úsala en el <h1> con una clase de Tailwind, sin tocar globals.css.
Ver solución
// app/ejercicios/fuentes.ts
import localFont from 'next/font/local'
// Fichero de definiciones: se importa desde donde haga falta
export const titulos = localFont({
src: '../_fuentes/Geist.woff2',
variable: '--font-titulos',
weight: '100 900',
display: 'swap',
})
// En app/ejercicios/page.tsx
import { titulos } from './fuentes'
<main className={`${titulos.variable} mx-auto max-w-4xl space-y-8 px-6 py-8`}>
<h1 className="font-[family-name:var(--font-titulos)] text-3xl font-black">Ejercicios</h1>
...
</main>
weight: '100 900'declara el rango de la fuente variable. Asífont-black(900) usa el grosor real de la fuente, en lugar de que el navegador “engorde” las letras artificialmente.titulos.variableen el<main>define la variable solo dentro de esa página. Como la fuente se usa en una página y no en el layout, solo se precarga en esa ruta.font-[family-name:var(--font-titulos)]es un valor arbitrario de Tailwind con indicación de tipo: le dice que la variable es una familia tipográfica. Si la fuente se usara en toda la aplicación, sería mejor añadir--font-titulos: var(--font-titulos)en@theme inliney escribir simplementefont-titulos. Fíjate en que ahí la variable se llamaría igual en los dos lados; coninlineno hay problema, porque Tailwind sustituye el valor directamente.
Ejercicio 5: Un tercer tema, sepia
Añade un tema sepia (fondo color papel, texto marrón) que el usuario pueda elegir. Modifica el script del <head> para aceptarlo y escribe el CSS. ¿Qué pasa con la variante dark: en ese tema?
Ver solución
El script admite una lista de temas válidos:
// app/ejercicios/scripts-tema.ts
export const SCRIPT_TRES_TEMAS = `(function () {
try {
var validos = ['claro', 'oscuro', 'sepia'];
var t = localStorage.getItem('tema');
if (validos.indexOf(t) === -1) {
t = matchMedia('(prefers-color-scheme: dark)').matches ? 'oscuro' : 'claro';
}
document.documentElement.dataset.tema = t;
} catch (e) {}
})();`
Y en globals.css, un bloque más de variables:
[data-tema="sepia"] {
--fondo: #f4ecd8;
--superficie: #fbf6e9;
--texto: #4b3621;
--texto-suave: #7a6248;
--marca: #8b4513;
--borde: #e0d2b4;
color-scheme: light;
}
Con esto, todo lo que usa los tokens (bg-superficie, text-marca…) cambia solo. En el selector, añade { valor: 'sepia', texto: 'Sepia' } a OPCIONES y amplía el tipo Preferencia y las comprobaciones de leerPreferencia.
La variante dark: no se activa con sepia, porque solo mira data-tema="oscuro". Es lo correcto: sepia es un tema claro, y por eso su color-scheme es light. Si quisieras estilos propios del tema sepia con Tailwind, podrías crear otra variante: @custom-variant sepia (&:where([data-tema="sepia"], [data-tema="sepia"] *)); y escribir sepia:shadow-none.
Usamos indexOf en lugar de includes porque el script no se transpila y queremos que funcione en cualquier navegador; los dos son válidos en los navegadores que admite Next 16, pero el hábito de escribir este script en JavaScript conservador evita sorpresas.
Ejercicio 6: Diagnostica el {}
Un compañero crea un proyecto con npx create-next-app@latest --yes, añade app/Aviso.module.css con una clase .aviso y la usa en page.tsx. El aviso sale sin estilo, console.log(estilos) en el servidor imprime {} y el build no da errores. Explica qué pasa y dale dos arreglos.
Ver solución
La plantilla de 16.4 configura Tailwind con esta regla en next.config.ts:
turbopack: {
rules: {
"*.css": { loaders: ["@tailwindcss/turbopack"], as: "*.css" },
},
},
El patrón *.css también encaja con Aviso.module.css. El cargador procesa el fichero y as: "*.css" hace que su salida se trate como Aviso.module.css.css, un CSS normal. Turbopack ya no lo trata como módulo: no renombra las clases y el import devuelve un objeto vacío. Como estilos.aviso es undefined, React no escribe el atributo class. El build termina bien porque no hay ningún error de sintaxis; solo da un aviso si el módulo usa :global.
Arreglo A, conservar el cargador con dos reglas:
const tailwind = "@tailwindcss/turbopack";
const esModulo = { path: /\.module\.css$/ };
const esSass = { path: /\.s[ac]ss/ };
turbopack: {
rules: {
"*.css": [
{ condition: { not: esModulo }, loaders: [tailwind], as: "*.css" },
{ condition: { all: [esModulo, { not: esSass }] }, loaders: [tailwind], as: "*.module.css" },
],
},
},
Arreglo B, volver a PostCSS: borrar la sección turbopack.rules, instalar @tailwindcss/postcss y crear postcss.config.mjs con plugins: ["@tailwindcss/postcss"].
Los dos se han comprobado en el laboratorio: tras cualquiera de ellos, el objeto vuelve a tener la clase y el HTML la muestra. Para detectarlo pronto en un proyecto nuevo, imprime Object.keys(estilos) la primera vez que uses un módulo.
Ejercicio 7: El tema en una cookie
Cambia el almacenamiento del tema de localStorage a una cookie tema, válida un año y para todo el sitio. El servidor no debe leerla (para no perder el prerenderizado). Escribe el script del <head> y la función que guarda la elección.
Ver solución
// app/ejercicios/scripts-tema.ts
export const SCRIPT_TEMA_COOKIE = `(function () {
try {
var m = document.cookie.match(/(?:^|; )tema=([^;]*)/);
var t = m ? decodeURIComponent(m[1]) : null;
if (t !== 'claro' && t !== 'oscuro') {
t = matchMedia('(prefers-color-scheme: dark)').matches ? 'oscuro' : 'claro';
}
document.documentElement.dataset.tema = t;
} catch (e) {}
})();`
export function guardarTemaEnCookie(tema: 'claro' | 'oscuro') {
document.documentElement.dataset.tema = tema
document.cookie = `tema=${encodeURIComponent(tema)}; path=/; max-age=31536000; SameSite=Lax`
}
- La expresión regular
(?:^|; )tema=([^;]*)buscatema=al principio dedocument.cookieo después de;, para no confundirla con otra cookie que termine entema(comosubtema). path=/hace que la cookie valga para todas las rutas; sin él, solo valdría para la ruta actual y sus hijas.max-age=31536000son 365 días en segundos. Sinmax-ageniexpiressería una cookie de sesión y se borraría al cerrar el navegador.SameSite=Laxes el valor por defecto en los navegadores modernos; escribirlo deja clara la intención.
¿Qué ganas frente a localStorage? Que la cookie viaja con cada petición, así que una ruta que ya sea dinámica por otros motivos (el panel de gestión, por ejemplo) puede leerla con cookies() sin coste extra. ¿Qué pierdes? Unos bytes en cada petición y el evento storage para sincronizar pestañas, que no existe para cookies.
Ejercicio 8: Etiquetas de estado sin clases dinámicas
Este componente no muestra colores. Explica por qué y arréglalo:
const colores = { pendiente: 'amber', enviado: 'blue', entregado: 'emerald', cancelado: 'red' }
export function EtiquetaEstado({ estado }: { estado: keyof typeof colores }) {
const c = colores[estado]
return <span className={`bg-${c}-100 text-${c}-900 rounded-full px-2 text-xs`}>{estado}</span>
}
Ver solución
Tailwind no ejecuta tu código: lee los ficheros como texto y genera CSS para las cadenas que parecen clases. En el fichero aparecen bg-${c}-100 y amber, pero nunca bg-amber-100, así que esa clase no existe en el CSS final. El HTML sí lleva class="bg-amber-100 ...", y por eso cuesta tanto de ver: el nombre es correcto, pero no tiene reglas detrás.
La solución es que cada clase completa aparezca literalmente:
// app/ejercicios/colores.tsx
type Estado = 'pendiente' | 'enviado' | 'entregado' | 'cancelado'
// Clases completas y literales: Tailwind las encuentra al leer este fichero
export const claseEstado: Record<Estado, string> = {
pendiente: 'bg-amber-100 text-amber-900',
enviado: 'bg-blue-100 text-blue-900',
entregado: 'bg-emerald-100 text-emerald-900',
cancelado: 'bg-red-100 text-red-900',
}
export function EtiquetaEstado({ estado }: { estado: Estado }) {
return <span className={`rounded-full px-2 py-0.5 text-xs ${claseEstado[estado]}`}>{estado}</span>
}
Record<Estado, string> tiene otra ventaja: si mañana se añade un estado devuelto al tipo, TypeScript marcará el objeto como incompleto. Si el color no se conoce al compilar (lo elige el usuario), no uses clases: pásalo como variable de CSS con style.
Ejercicio 9 (propuesto): Tabla de empleados con CSS Module
Crea una tabla de empleados con un CSS Module que tenga cabecera fija al desplazar (position: sticky), filas alternas con color usando los tokens del tema, resaltado de la fila al pasar el ratón y una fila “seleccionada” marcada con aria-selected="true" y un selector de atributo. Comprueba que se ve bien en los dos temas sin escribir ningún dark:.
Ejercicio 10 (propuesto): Medir el salto de las fuentes
Crea dos páginas con el mismo texto largo en una fuente de Google: una con adjustFontFallback: false y display: 'swap', y otra con los valores por defecto. Con las herramientas de desarrollo (pestaña Rendimiento o Lighthouse) y la red limitada a “3G lenta”, mide el Cumulative Layout Shift de cada una. Explica la diferencia a partir del @font-face que genera Next para '... Fallback'.
Ejercicio 11 (propuesto): Un tema por sección
La zona de gestión de EMPRESA debe tener un color de marca distinto (verde) al de la tienda (azul), en los dos temas. Consíguelo redefiniendo variables en un elemento envolvente del layout de la sección, sin importar CSS global en ese layout (recuerda el apartado 2.1) y sin duplicar componentes.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| CSS global | import './globals.css' en el layout raíz | No se retira al navegar: solo para lo verdaderamente global |
| Hojas de estilo en el HTML | <link rel="stylesheet" data-precedence> | React 19 las ordena y no las duplica; los Server Components no envían JS por sus estilos |
scroll-behavior 🔄 16.0 | data-scroll-behavior="smooth" en <html> | Next 16 ya no lo desactiva al navegar salvo que lo pidas |
| CSS Modules | import estilos from './x.module.css' | Objeto con nombres únicos por fichero; undefined si la clave no existe |
| Nombres cortos 🔄 16.4 | Poillq_tarjeta | No escribas nunca a mano un nombre generado |
| Composición | composes: caja from './base.module.css' | Añade una clase, no copia el CSS |
| Escape de ámbito | :global(...) | Para reaccionar a algo de fuera, como data-tema en <html> |
| Variantes | data-* + selector de atributo | Estado legible en el HTML, una sola clase |
| Valores dinámicos | style={{ '--var': valor }} | El dato en una variable, la presentación en el CSS |
| Tailwind 4 🔄 16.4 | @tailwindcss/turbopack o @tailwindcss/postcss | La regla *.css de la plantilla rompe los módulos: sepáralos o usa PostCSS |
| Tokens del tema | @theme inline { --color-x: var(--x) } | Las utilidades siguen a variables que cambian con el tema |
| Tailwind en módulos | @reference + @apply | Valores del tema sin duplicar Tailwind |
| Clases dinámicas | Objeto con clases literales | Tailwind lee texto, no ejecuta tu código |
| Sass | npm i -D sass, .module.scss | Parciales, @use, mixins y bucles al compilar |
sassOptions | additionalData, implementation | additionalData no llega a los parciales de @use |
~ en Sass 🔄 16.0 | turbopack.resolveAlias | Turbopack no admite el prefijo ~ |
:export | Variables de CSS en su lugar | Turbopack no lo implementa |
| CSS-in-JS | Registro + useServerInsertedHTML | Solo en Client Components; styled-jsx viene incluido |
| Fuentes de Google | next/font/google | Se descargan al compilar y se sirven desde tu dominio |
| Fuentes locales | next/font/local | src relativo al fichero que llama a localFont |
| Aplicar fuentes | className, style, variable | variable en <html> + @theme inline para Tailwind |
| Precarga | Cabecera Link o <link rel="preload"> | Solo en las rutas que usan la fuente |
| Tema sin parpadeo | Script en <head> + suppressHydrationWarning | Se aplica antes del primer pintado, sin esperar a React |
dark: con atributo | @custom-variant dark (...) | La variante sigue la elección del usuario, no solo la del sistema |
Leer localStorage en React | useSyncExternalStore | getServerSnapshot evita errores de hidratación |
| Tema en el servidor | cookies() en el layout | Hace dinámica toda la aplicación: mejor leer la cookie en el script |
| Agrupación de CSS 🆕 16.3 | experimental.cssChunking: 'graph' | Equilibra bytes y peticiones entre rutas |
| CSS en línea | experimental.inlineCss | Mejora la primera visita, pierde la caché del CSS |