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
El boilerplate ahora incluye el endpoint receptor para publicar posts desde un provider custom de Postiz. Calendario editorial unificado con tus redes sociales en menos de 2 horas.