Next.js + Tailwind v4 + shadcn/ui
Arma un proyecto Next.js 15 con Tailwind CSS v4 y shadcn/ui — la pila exacta usada por este cookbook. App Router, React 19, TypeScript strict mode, Tailwind configurado vía CSS (no JS), y componentes shadcn que posees y editas directamente en components/ui/.
Receta
Tarjeta de receta de referencia rápida — lista para copiar y pegar.
# 1. Crea la app Next.js con Tailwind v4 integrado
npx create-next-app@latest my-app \
--typescript \
--tailwind \
--app \
--no-src-dir \
--import-alias "@/*"
cd my-app
# 2. Inicializa shadcn/ui — respuestas: estilo New York, base Zinc, variables CSS
npx shadcn@latest init
# 3. Agrega un par de componentes
npx shadcn@latest add button cardCuándo usarlo: Cualquier producto real. Tailwind v4 te da iteración rápida y CSS pequeño; shadcn te da primitivos accesibles y editables sin el bloqueo de una librería de componentes.
Ejemplo funcional
Después de la receta tendrás esta estructura:
my-app/
├── app/
│ ├── globals.css # Tailwind v4 + variables CSS de shadcn
│ ├── layout.tsx
│ └── page.tsx
├── components/
│ └── ui/
│ ├── button.tsx # agregado por shadcn
│ └── card.tsx # agregado por shadcn
├── lib/
│ └── utils.ts # el helper cn
├── components.json # configuración shadcn
├── next.config.ts
├── package.json
├── postcss.config.mjs
└── tsconfig.jsonlib/utils.ts es el fusionador de nombres de clase en el que dependen los componentes de shadcn:
// lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}components.json le dice al CLI de shadcn dónde poner archivos y qué alias usar:
// components.json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"ui": "@/components/ui",
"utils": "@/lib/utils",
"lib": "@/lib",
"hooks": "@/hooks"
}
}Tailwind v4 se configura dentro de app/globals.css — ya no hay archivo tailwind.config.js:
/* app/globals.css (abreviado) */
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--radius: 0.625rem;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--radius-lg: var(--radius);
}Una página que usa los componentes instalados Button y Card — es un Server Component, no se necesita "use client":
// app/page.tsx
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function HomePage() {
return (
<main className="mx-auto max-w-md p-8">
<Card>
<CardHeader>
<CardTitle>Bienvenido</CardTitle>
<CardDescription>
Next.js 15 + Tailwind v4 + shadcn/ui
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Cada pieza aquí es código fuente editable que posees.
</p>
</CardContent>
<CardFooter>
<Button>Comenzar</Button>
</CardFooter>
</Card>
</main>
);
}Análisis profundo
Cómo funciona
create-next-app --tailwind escribe en app/globals.css con @import "tailwindcss", agrega @tailwindcss/postcss a postcss.config.mjs, y agrega Tailwind v4 a package.json. No hay tailwind.config.js; Tailwind v4 lee la configuración desde CSS usando @theme, @custom-variant, y at-rules relacionadas.
npx shadcn@latest init inspecciona tu proyecto, confirma el framework (Next.js App Router), pregunta por un color base y estilo, luego escribe components.json, lib/utils.ts, y agrega variables CSS y un bloque @theme inline a app/globals.css. No instala una librería de componentes — cada npx shadcn add <nombre> obtiene el código fuente de ese componente del registro de shadcn y copia el .tsx sin procesar en components/ui/. Lo posees desde ese momento en adelante.
El helper cn() fusiona nombres de clase en dos capas: clsx maneja condicionales y arrays, luego twMerge deduplica clases Tailwind conflictivas (p.ej. px-2 gana sobre px-4 cuando ambas aparecen). Cada componente shadcn lo usa para la propiedad className para que puedas anular defaults sin guerras de especificidad.
Variaciones
- Color base: en el momento de
init, eligeslate,gray,zinc(por defecto en este cookbook),neutral, ostone. Puedes cambiarlo más tarde volviendo a ejecutarnpx shadcn@latest inito editando manualmente las variables CSS. - Dark mode con next-themes:
npm install next-themes, envuelve<body>en un clienteThemeProvider, y la variante de clase.darkde shadcn funciona directamente porque@custom-variant dark (&:is(.dark *))ya está englobals.css. - Temas personalizados: edita las variables CSS
:rooty.darkenglobals.css; no se necesita paso de reconstrucción, Tailwind v4 las detecta. - Iconos Lucide:
npm install lucide-react, luegoimport \{ ArrowRight \} from "lucide-react". Los componentes shadcn usan Lucide internamente por lo que ya es una dependencia transitiva. - Instala un componente específico:
npx shadcn@latest add dialog form input select. Las dependencias (como primitivos de Radix) se instalan automáticamente. - Monorepo: usa
npx shadcn@latest initdesde dentro de tu workspace de app, establece los alias decomponents.jsonpara que coincidan con las rutas TS de tu workspace, y apuntatailwind.cssalglobals.cssmás cercano.
Notas de TypeScript
La firma de cn es cn(...inputs: ClassValue[]): string, donde ClassValue es la unión de clsx de string | number | boolean | undefined | null | ClassDictionary | ClassArray. Eso significa que puedes pasar objetos condicionales como cn("p-4", \{ "bg-primary": isActive \}) y TS no se quejará.
Los componentes personalizados tipo botón deben extender el tipo de propiedad nativa para que las refs, aria-*, y manejadores de eventos vengan gratis:
import * as React from "react";
import { cn } from "@/lib/utils";
type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
variant?: "default" | "outline";
};
export function MyButton({ className, variant = "default", ...props }: ButtonProps) {
return (
<button
className={cn(
"rounded-md px-4 py-2",
variant === "outline" && "border",
className,
)}
{...props}
/>
);
}El propio Button de shadcn usa class-variance-authority (cva) y expone un tipo VariantProps<typeof buttonVariants> para que cada combinación de variante sea type-safe.
Trampas
- shadcn pregunta sobre TypeScript y JavaScript por separado. Incluso si tu app Next es TypeScript, el prompt de init de shadcn preguntará de nuevo. Di sí, o escribirá archivos
.jsxen un proyecto TS. - La configuración de Tailwind v4 vive en CSS, no en JS. Si una publicación de blog te dice que edites
tailwind.config.js, eso es Tailwind v3. En v4 el archivo no existe; usa@themedentro deapp/globals.css. - Los alias de
components.jsondeben coincidir con las rutas detsconfig.json. Si cambias"@/*"entsconfig.jsonpero no encomponents.json, los componentes shadcn recién agregados importarán desde una ruta que no se resuelve. - Estrategia de clase de dark mode vs media. shadcn por defecto usa dark mode basado en clases vía
@custom-variant dark (&:is(.dark *)). Si quieresprefers-color-scheme, debes editar esa línea a@custom-variant dark (&:is(@media (prefers-color-scheme: dark)))— el default hará nada silenciosamente sin una clase.darken un antecesor. cn()no es opcional. Si la dejas y concatenas strings de clase con literales de plantilla, las clases Tailwind conflictivas ya no se fusionarán y los overrides de props perderán aleatoriamente.@import "tailwindcss"debe ser la primera línea deglobals.css. Poner variables CSS por encima de la importación rompe el ordenamiento de capas de Tailwind v4 y tus clasestext-primaryaparecerán sin estilo.postcss.config.mjsdebe usar@tailwindcss/postcss, notailwindcss. Tailwind v4 movió el plugin de PostCSS a un paquete separado; el nombre de plugin antiguo lanza un error en tiempo de compilación.
Alternativas
| Librería | Cuándo usarla | Compensación vs shadcn |
|---|---|---|
| Radix Themes | Quieres un sistema de diseño pre-estilizado, listo para usar | Menos personalización; no posees el código fuente del componente |
| Park UI (Panda CSS) | Prefieres una solución CSS-in-JS de tiempo de compilación | Motor de estilos diferente; ecosistema más pequeño |
| Mantine UI | Quieres un conjunto exhaustivo de componentes listo para usar | Bundle más grande; overrides vía props, no ediciones de fuente |
| Chakra UI v3 | Te gustan los style props y los tokens de tema | CSS-in-JS en tiempo de ejecución; más pesado que Tailwind v4 |
| Hero UI (formerly NextUI) | Quieres componentes motion-first y nativos de Tailwind | El código fuente del componente está en npm, no copiado en tu repo |
FAQs
Revisión rápida de esta página — haz clic para expandir.
¿Dónde almacena Tailwind v4 su configuración?
Dentro de app/globals.css usando @theme, @custom-variant, y propiedades personalizadas de CSS. No hay tailwind.config.js en un proyecto Tailwind v4.
¿Instala npx shadcn@latest init una librería de componentes?
No. Instala clsx, tailwind-merge, class-variance-authority, y escribe components.json más lib/utils.ts. Los componentes se agregan uno a la vez y se copian en components/ui/ como código fuente sin procesar.
¿Qué hace cn() que las strings de plantilla no pueden?
Ejecuta twMerge para que los servicios de Tailwind conflictivos colapsen (p.ej. cn("p-2", "p-4") se convierte en "p-4"), lo que preserva el comportamiento de override esperado de props.
¿Puedo usar componentes shadcn dentro de Server Components?
Sí, la mayoría de ellos. Cualquier componente que use un primitivo de Radix con estado (Dialog, DropdownMenu) necesitará "use client" en el archivo del componente — shadcn ya agrega esa directiva donde sea necesaria.
¿Cómo cambio el color base después de init?
Edita las variables CSS en :root y .dark dentro de app/globals.css, o vuelve a ejecutar npx shadcn@latest init y elige un nuevo preset.
¿A qué archivo se resuelve @/lib/utils?
./lib/utils.ts relativo a la raíz del proyecto, porque tsconfig.json tiene "paths": \{ "@/*": ["./*"] \} y components.json lo refleja.
¿Agrupa shadcn los iconos de Lucide?
lucide-react se instala como una dependencia cuando agregas componentes que usan iconos. Puedes importar cualquier icono de Lucide directamente una vez que esté en package.json.
¿Por qué mi clase bg-primary no muestra color?
Generalmente porque @import "tailwindcss" no es la primera línea de globals.css, o el bloque @theme inline no hace referencia a tu variable --primary. Trampa: el orden de capas importa en Tailwind v4.
¿Por qué el dark mode no hace nada en mi página?
shadcn usa dark mode basado en clases, por lo que una clase .dark tiene que aplicarse a <html> o un antecesor. Agrega next-themes o alterna la clase tú mismo. Trampa: por defecto esto no es prefers-color-scheme.
¿Cuál es el tipo de la propiedad className en un Button de shadcn?
string | undefined. Nota de TS: el tipo de propiedad completo es React.ComponentProps<"button"> & VariantProps<typeof buttonVariants>, por lo que las variantes y los atributos de botón nativos son type-safe.
¿Cómo escribo un componente que envuelve un Button de shadcn?
Usa React.ComponentPropsWithoutRef<typeof Button> (o React.ComponentProps<typeof Button>) para que tu wrapper herede variant, size, y propiedades nativas <button>. Nota de TS: prefiere ComponentPropsWithoutRef a menos que intencionalmente reenvíes refs.
¿Puedo usar esta pila sin el App Router?
Técnicamente sí — shadcn soporta Pages Router — pero este cookbook se enfoca en el App Router exclusivamente y el components.json generado asume que RSC está habilitado.