La integración real de Tailwind v4 + shadcn 2.3.0 en Next.js 16 App Router. OKLCH, dark mode sin FOUC, Geist con next/font y el bug silencioso de hsl(var()) que rompe tus estilos sin avisar.
Llevamos un par de años con la duda de "qué librería de componentes usar". En 2026 esa duda se resolvió: Tailwind v4 + shadcn/ui sobre Next.js 16 App Router. Pero el setup real, cuando lo haces, tiene cinco o seis trampas que ningún post junta en un sitio.
Esta guía es esa pieza que faltaba. Sale del setup actual del boilerplate de CREA.MBA — ni inventado, ni reescrito para parecer limpio.
Hay un buen post sobre por qué Next.js 16 con App Router es el default. Hay otro sobre Tailwind v4 + shadcn/ui en 2026. Los dos están bien. Pero ninguno cubre la integración real.
Lo que pasa cuando juntas las tres piezas es donde la cosa se tuerce:
→ El tailwind.config.js ya no existe, y nadie te dice dónde poner las custom variants.
→ shadcn 2.3.0 migró de HSL a OKLCH y mucho código antiguo se queda inválido sin avisar.
→ Dark mode con Server Components tiene un FOUC casi imposible de quitar si no conoces tres props específicas.
→ Las animaciones de shadcn (Dialog, Sheet, Drawer) necesitan un paquete distinto al de Tailwind v3.
→ next/font y el sistema de tokens de Tailwind v4 no se hablan por defecto.
Vamos uno a uno con el código exacto.
Empezamos por las dependencias. En package.json:
{
"dependencies": {
"next": "^16.2.6",
"react": "19.2.3",
"next-themes": "^0.4.6",
"tailwind-merge": "^3.4.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1"
},
"devDependencies": {
"tailwindcss": "^4.1.18",
"@tailwindcss/postcss": "^4.1.18",
"tw-animate-css": "^1.4.0"
}
}Tres detalles que se pasan por alto:
→ tailwindcss-animate ya no funciona en v4. Lo sustituye tw-animate-css.
→ @tailwindcss/postcss es el plugin oficial para Next.js. Sustituye tailwindcss como plugin PostCSS.
→ No hay tailwindcss-cli ni tailwind.config.js. Todo se configura en CSS.
PostCSS config (postcss.config.mjs):
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;Sí, es así de corto. v3 tenía cinco o seis líneas con autoprefixer, tailwindcss/nesting, postcss-import. v4 lo trae todo en uno.
shadcn config (components.json):
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/shared/components",
"utils": "@/shared/utils",
"ui": "@/shared/components/ui",
"lib": "@/lib",
"hooks": "@/shared/hooks"
}
}Tres claves:
→ "config": "" — vacío. No hay tailwind.config.js en v4.
→ "rsc": true — usas Server Components.
→ "cssVariables": true — la paleta vive en variables CSS, no en clases.
Aquí pasa todo. Importa Tailwind, define variantes, mapea variables al theme, declara la paleta en :root y .dark. Estructura completa:
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border);
--color-ring: var(--ring);
/* ... el resto de colores shadcn */
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.75rem;
--background: oklch(0.985 0.007 65);
--foreground: oklch(0.155 0.015 55);
--primary: oklch(0.21 0.02 265);
--primary-foreground: oklch(0.985 0.003 65);
--muted: oklch(0.955 0.01 65);
--muted-foreground: oklch(0.52 0.015 55);
--border: oklch(0.92 0.01 65);
--ring: oklch(0.708 0.015 265);
/* ... */
}
.dark {
--background: oklch(0.23 0.008 260);
--foreground: oklch(0.985 0.005 270);
/* ... */
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground font-sans;
}
}Cuatro cosas importantes:
→ @custom-variant dark (&:is(.dark *)); sustituye lo que en v3 era darkMode: 'class' en el config. Sin config, defines la variant en CSS.
→ @theme inline { ... } es el bloque que conecta tus variables custom con el sistema Tailwind. Sin esto, bg-primary no funciona aunque tengas --primary definida.
→ Los colores van en OKLCH, no en HSL. Shadcn 2.3.0+ migró por una razón: OKLCH cubre el espacio de color P3 (los monitores modernos), y manipularlo (oscurecer, aclarar, mezclar) da resultados perceptualmente correctos. HSL no.
→ font-sans se mapea a --font-geist-sans vía @theme inline. Eso hace que la clase font-sans use tu fuente Geist automáticamente.
El truco para que next/font y Tailwind v4 se hablen es esta cadena de tres pasos.
En layout.tsx (root):
import { Geist, Geist_Mono } from 'next/font/google';
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
});
export default function RootLayout({ children }: { children: ReactNode }) {
return (
{children}
);
}Y en globals.css ya está el otro extremo de la cadena:
@theme inline {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}Cadena completa: clase font-sans (Tailwind) → variable --font-sans (theme Tailwind v4) → variable --font-geist-sans (next/font). Toda la app usa Geist sin imports adicionales, sin en, sin layout shift.
Tres props específicas, una directiva HTML y un patrón en el toggle. Si te falta uno, ves el flash blanco.
1. ThemeProvider envuelve la app:
// src/shared/components/theme-provider.tsx
'use client';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import type { ComponentProps } from 'react';
type ThemeProviderProps = ComponentProps;
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return {children};
}'use client' es obligatorio. next-themes usa context y hooks.
2. Aplícalo en el root layout:
{children}
Las tres props críticas:
→ attribute="class" — la clase .dark (o .light) se aplica al ``, lo que activa tu variant &:is(.dark *).
→ defaultTheme="system" + enableSystem — respeta la preferencia del SO del usuario.
→ disableTransitionOnChange — evita que cuando cambias de modo claro a oscuro se anime el cambio (queda raro porque todos los colores transicionan a la vez).
Y suppressHydrationWarning en evita el warning de React que aparecería porque next-themes inyecta la clase en el servidor antes de hidratar (el cliente y el servidor verán diferentes clases en durante un instante).
3. El toggle: patrón useSyncExternalStore para detectar mount:
// src/shared/components/theme-toggle.tsx
'use client';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { useSyncExternalStore } from 'react';
import { Button } from '@/shared/components/ui/button';
function useMounted() {
return useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
}
export function ThemeToggle() {
const { setTheme, theme } = useTheme();
const mounted = useMounted();
if (!mounted) {
return (
);
}
return (
setTheme(theme === 'dark' ? 'light' : 'dark')}
>
);
}useSyncExternalStore retorna false en servidor y true en cliente. Es la API oficial de React para suscribirse a estados externos al árbol React, y es más eficiente que el patrón clásico useState + useEffect porque no causa un re-render adicional.
Sin este patrón el toggle parpadearía al hidratar, porque theme es undefined en SSR.
Si vienes de Tailwind v3 con shadcn, tenías tailwindcss-animate como plugin. En v4 no funciona — el plugin asume config JS que ya no existe. Cambias a tw-animate-css y lo importas como CSS:
@import 'tw-animate-css';Las clases siguen siendo las mismas (animate-in, slide-in-from-top, etc.), pero el paquete que las provee es otro.
hsl(var(--X)) después de migrar a OKLCHEs el bug más silencioso de la migración. Tenías:
color: hsl(var(--muted-foreground));Cuando --muted-foreground era "0 0% 55%" (componentes HSL sueltos), esto funcionaba. Pero al migrar a OKLCH:
--muted-foreground: oklch(0.52 0.015 55);hsl(var(--muted-foreground)) evalúa a hsl(oklch(0.52 0.015 55)), que es CSS inválido. Tu navegador se calla, ignora la regla, y los estilos se rompen sin error visible en consola.
Solución: tras migrar a OKLCH, usa var(--X) directamente:
color: var(--muted-foreground);Si necesitas alpha, usa color-mix:
border-color: color-mix(in oklch, var(--foreground) 40%, transparent);'use client' cuando hace faltaNo te preocupes de añadir 'use client' a Dialog, Sheet, Dropdown, etc. Ya lo traen ellos internamente. Solo tienes que importarlos:
import { Dialog } from '@/shared/components/ui/dialog';Tu página puede ser Server Component, y solo el Dialog (y sus hijos interactivos) corren en el cliente. Esa es la ventaja de App Router con shadcn.
→ postcss.config.mjs: solo @tailwindcss/postcss.
→ globals.css: @import 'tailwindcss', @import 'tw-animate-css', @custom-variant dark, @theme inline con tus tokens, paleta OKLCH en :root y .dark.
→ components.json: style: new-york, rsc: true, tailwind.config: "", cssVariables: true.
→ layout.tsx: ``, fonts Geist como variables, ThemeProvider envolviendo todo con attribute="class", defaultTheme="system", enableSystem, disableTransitionOnChange.
→ theme-toggle.tsx: patrón useSyncExternalStore para detectar mount sin hydration mismatch.
Es la combinación que llevo en producción en CREA.MBA y en el boilerplate. Sin FOUC, sin warnings de hidratación, sin estilos rotos al cambiar de tema.
Si tienes una app vieja con HSL hardcoded, sustituye hsl(var(--X)) por var(--X) el mismo día que actualizas la paleta. No lo dejes para "después" — vas a pasar una semana preguntándote por qué tu blog no tiene colores.
Suscríbete para más tutoriales y tips sobre crear productos con IA