React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

nextjstailwindshadcnsetupapp-routertypescript

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 card

When 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.json

lib/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 init time, pick slate, gray, zinc (this cookbook's default), neutral, or stone. You can change it later by re-running npx shadcn@latest init or hand-editing the CSS variables.
  • Dark mode with next-themes: npm install next-themes, wrap <body> in a client ThemeProvider, and shadcn's .dark class variant just works because @custom-variant dark (&:is(.dark *)) is already in globals.css.
  • Custom theming: edit the :root and .dark CSS variables in globals.css; no rebuild step needed, Tailwind v4 picks them up.
  • Lucide icons: npm install lucide-react, then import \{ 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 init from inside your app workspace, set the components.json aliases to match your workspace's TS paths, and point tailwind.css at the nearest globals.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

  1. 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 .jsx files into a TS project.
  2. 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 @theme inside app/globals.css.
  3. components.json aliases must match tsconfig.json paths. If you change "@/*" in tsconfig.json but not components.json, newly added shadcn components will import from a path that does not resolve.
  4. Dark mode class vs media strategy. shadcn defaults to class-based dark mode via @custom-variant dark (&:is(.dark *)). If you want prefers-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 .dark class on an ancestor.
  5. 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.
  6. @import "tailwindcss" must be the very first line of globals.css. Putting CSS variables above the import breaks Tailwind v4's layer ordering and your text-primary classes will appear unstyled.
  7. postcss.config.mjs must use @tailwindcss/postcss, not tailwindcss. Tailwind v4 moved the PostCSS plugin to a separate package; the old plugin name throws at build time.

Alternatives

LibraryWhen to use itTrade-off vs shadcn
Radix ThemesYou want a pre-styled, batteries-included design systemLess customization; you do not own the component source
Park UI (Panda CSS)You prefer a CSS-in-JS compile-time solutionDifferent styling engine; smaller ecosystem
Mantine UIYou want an exhaustive component set out of the boxLarger bundle; overrides via props, not source edits
Chakra UI v3You like style props and theme tokensRuntime CSS-in-JS; heavier than Tailwind v4
Hero UI (formerly NextUI)You want motion-first, Tailwind-native componentsComponent 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.