Next.js + Tailwind v4 + shadcn/ui
Scaffold a Next.js 15 project with Tailwind CSS v4 and shadcn/ui — the exact stack used by this cookbook. App Router, React 19, TypeScript strict mode, Tailwind configured via CSS (not JS), and shadcn components you own and edit directly in components/ui/.
Recipe
Quick-reference recipe card — copy-paste ready.
# 1. Create the Next.js app with Tailwind v4 baked in
npx create-next-app@latest my-app \
--typescript \
--tailwind \
--app \
--no-src-dir \
--import-alias "@/*"
cd my-app
# 2. Initialize shadcn/ui — answers: New York style, Zinc base, CSS vars
npx shadcn@latest init
# 3. Add a couple of components
npx shadcn@latest add button cardWhen to reach for this: Any real product. Tailwind v4 gives you fast iteration and small CSS; shadcn gives you accessible, editable primitives without the lock-in of a component library.
Working Example
After the recipe you will have this shape:
my-app/
├── app/
│ ├── globals.css # Tailwind v4 + shadcn CSS variables
│ ├── layout.tsx
│ └── page.tsx
├── components/
│ └── ui/
│ ├── button.tsx # added by shadcn
│ └── card.tsx # added by shadcn
├── lib/
│ └── utils.ts # the cn helper
├── components.json # shadcn config
├── next.config.ts
├── package.json
├── postcss.config.mjs
└── tsconfig.jsonlib/utils.ts is the class-name merger shadcn components depend on:
// 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 tells the shadcn CLI where to put files and which aliases to use:
// 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 is configured inside app/globals.css — there is no tailwind.config.js file anymore:
/* app/globals.css (abridged) */
@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);
}A page that uses the installed Button and Card components — it is a Server Component, no "use client" needed:
// 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>Welcome</CardTitle>
<CardDescription>
Next.js 15 + Tailwind v4 + shadcn/ui
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Every piece here is editable source you own.
</p>
</CardContent>
<CardFooter>
<Button>Get started</Button>
</CardFooter>
</Card>
</main>
);
}Deep Dive
How It Works
create-next-app --tailwind writes app/globals.css with @import "tailwindcss", adds @tailwindcss/postcss to postcss.config.mjs, and adds Tailwind v4 to package.json. There is no tailwind.config.js; Tailwind v4 reads configuration from CSS using @theme, @custom-variant, and related at-rules.
npx shadcn@latest init inspects your project, confirms the framework (Next.js App Router), asks for a base color and style, then writes components.json, lib/utils.ts, and appends CSS variables and a @theme inline block to app/globals.css. It does not install a component library — each npx shadcn add <name> fetches that component's source from the shadcn registry and drops the raw .tsx into components/ui/. You own it from that moment forward.
The cn() helper merges class names two layers deep: clsx handles conditionals and arrays, then twMerge deduplicates conflicting Tailwind classes (e.g. px-2 beats px-4 when both appear). Every shadcn component uses it for the className prop so you can override defaults without specificity fights.
Variations
- Base color: at
inittime, pickslate,gray,zinc(this cookbook's default),neutral, orstone. You can change it later by re-runningnpx shadcn@latest initor hand-editing the CSS variables. - Dark mode with next-themes:
npm install next-themes, wrap<body>in a clientThemeProvider, and shadcn's.darkclass variant just works because@custom-variant dark (&:is(.dark *))is already inglobals.css. - Custom theming: edit the
:rootand.darkCSS variables inglobals.css; no rebuild step needed, Tailwind v4 picks them up. - Lucide icons:
npm install lucide-react, thenimport \{ ArrowRight \} from "lucide-react". shadcn components use Lucide internally so it is already a transitive dep. - Install a specific component:
npx shadcn@latest add dialog form input select. Dependencies (like Radix primitives) are installed automatically. - Monorepo: use
npx shadcn@latest initfrom inside your app workspace, set thecomponents.jsonaliases to match your workspace's TS paths, and pointtailwind.cssat the nearestglobals.css.
TypeScript Notes
The cn signature is cn(...inputs: ClassValue[]): string, where ClassValue is clsx's union of string | number | boolean | undefined | null | ClassDictionary | ClassArray. That means you can pass conditional objects like cn("p-4", \{ "bg-primary": isActive \}) and TS will not complain.
Custom button-like components should extend the native prop type so refs, aria-*, and event handlers come for free:
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}
/>
);
}shadcn's own Button uses class-variance-authority (cva) and exposes a VariantProps<typeof buttonVariants> type so every variant combination is type-safe.
Gotchas
- shadcn asks about TypeScript and JavaScript separately. Even if your Next app is TypeScript, the shadcn init prompt will ask again. Say yes, or it will write
.jsxfiles into a TS project. - Tailwind v4 config lives in CSS, not JS. If a blog post tells you to edit
tailwind.config.js, that is Tailwind v3. In v4 the file does not exist; use@themeinsideapp/globals.css. components.jsonaliases must matchtsconfig.jsonpaths. If you change"@/*"intsconfig.jsonbut notcomponents.json, newly added shadcn components will import from a path that does not resolve.- Dark mode class vs media strategy. shadcn defaults to class-based dark mode via
@custom-variant dark (&:is(.dark *)). If you wantprefers-color-scheme, you must edit that line to@custom-variant dark (&:is(@media (prefers-color-scheme: dark)))— the default will silently do nothing without a.darkclass on an ancestor. cn()is not optional. If you drop it and concatenate class strings with template literals, conflicting Tailwind classes will no longer be merged and overrides from props will randomly lose.@import "tailwindcss"must be the very first line ofglobals.css. Putting CSS variables above the import breaks Tailwind v4's layer ordering and yourtext-primaryclasses will appear unstyled.postcss.config.mjsmust use@tailwindcss/postcss, nottailwindcss. Tailwind v4 moved the PostCSS plugin to a separate package; the old plugin name throws at build time.
Alternatives
| Library | When to use it | Trade-off vs shadcn |
|---|---|---|
| Radix Themes | You want a pre-styled, batteries-included design system | Less customization; you do not own the component source |
| Park UI (Panda CSS) | You prefer a CSS-in-JS compile-time solution | Different styling engine; smaller ecosystem |
| Mantine UI | You want an exhaustive component set out of the box | Larger bundle; overrides via props, not source edits |
| Chakra UI v3 | You like style props and theme tokens | Runtime CSS-in-JS; heavier than Tailwind v4 |
| Hero UI (formerly NextUI) | You want motion-first, Tailwind-native components | Component source is in npm, not copied into your repo |
FAQs
Quick review of this page — click to expand.
Where does Tailwind v4 store its config?
Inside app/globals.css using @theme, @custom-variant, and CSS custom properties. There is no tailwind.config.js in a Tailwind v4 project.
Does npx shadcn@latest init install a component library?
No. It installs clsx, tailwind-merge, class-variance-authority, and writes components.json plus lib/utils.ts. Components are added one at a time and copied into components/ui/ as raw source.
What does cn() do that template strings cannot?
It runs twMerge so conflicting Tailwind utilities collapse (e.g. cn("p-2", "p-4") becomes "p-4"), which preserves expected override behavior from props.
Can I use shadcn components inside Server Components?
Yes, most of them. Any component that uses a Radix primitive with state (Dialog, DropdownMenu) will need "use client" in the component file — shadcn already adds that directive where needed.
How do I change the base color after init?
Edit the CSS variables in :root and .dark inside app/globals.css, or re-run npx shadcn@latest init and pick a new preset.
What file does @/lib/utils resolve to?
./lib/utils.ts relative to the project root, because tsconfig.json has "paths": \{ "@/*": ["./*"] \} and components.json mirrors it.
Does shadcn bundle Lucide icons?
lucide-react is installed as a dependency when you add components that use icons. You can import any Lucide icon directly once it is in package.json.
Why is my bg-primary class showing no color?
Usually because @import "tailwindcss" is not the first line of globals.css, or the @theme inline block does not reference your --primary variable. Gotcha: layer order matters in Tailwind v4.
Why does dark mode do nothing on my page?
shadcn uses class-based dark mode, so a .dark class has to be applied to <html> or an ancestor. Add next-themes or toggle the class yourself. Gotcha: this is not prefers-color-scheme by default.
What is the type of the className prop on a shadcn Button?
string | undefined. TS note: the full prop type is React.ComponentProps<"button"> & VariantProps<typeof buttonVariants>, so variants and native button attributes are type-safe.
How do I type a component that wraps shadcn Button?
Use React.ComponentPropsWithoutRef<typeof Button> (or React.ComponentProps<typeof Button>) so your wrapper inherits variant, size, and native <button> props. TS note: prefer ComponentPropsWithoutRef unless you intentionally forward refs.
Can I use this stack without the App Router?
Technically yes — shadcn supports Pages Router — but this cookbook targets the App Router exclusively and the generated components.json assumes RSC is enabled.