shadcn/ui with Next.js 16: CLI Setup, Theming, and the Component Registry (2026)

Install shadcn/ui in Next.js 16 with the CLI, configure Tailwind v4 OKLCH theming, wire up dark mode with next-themes, and set up a private component registry for teams.

shadcn/ui + Next.js 16: 2026 Setup Guide

Updated: July 28, 2026

shadcn/ui with Next.js 16 is a copy-paste component collection built on Radix UI primitives and Tailwind CSS v4. You install components into your own components/ui folder using the shadcn CLI, then own and modify them like any other file in your codebase. Unlike traditional npm libraries, there's no versioned package to upgrade and no black-box abstraction. Every button, dialog, and data table lives in your repo as editable TypeScript. In this guide I'll walk through the full 2026 setup: shadcn@latest init, Tailwind v4 @theme tokens, dark mode with next-themes, Server Component boundaries, and building a private component registry for teams.

  • shadcn/ui is not an npm library. The CLI writes source files directly into your repo, so you own every line of component code.
  • The 2026 CLI ([email protected]) targets Tailwind CSS v4's CSS-first @theme block and uses OKLCH color tokens by default.
  • Most primitives render fine as Server Components. Only interactive ones (Dialog, DropdownMenu, Sheet) need "use client".
  • Dark mode is delegated to next-themes. The CLI wires up CSS variables, but you install and mount the provider yourself.
  • Private component registries let teams share design-system components across repos without publishing to npm.
  • The cn() utility (clsx + tailwind-merge) is the seam that makes every shadcn component composable and overridable.

What is shadcn/ui and is it a component library?

Technically no. shadcn/ui is a component distribution system, not a component library. When you run npx shadcn@latest add button, the CLI fetches the source of button.tsx from the registry and drops it into your project's components/ui directory. There's no @shadcn/react package in your node_modules. That's the entire philosophy: you own the components, you can edit them freely, and you're not blocked by upstream API decisions. In my experience shipping design systems on top of Radix, this trade-off pays off within the first quarter. Every component library eventually needs "just one small change" that requires forking, and shadcn/ui makes forking the default state.

Under the hood, each interactive component wraps a headless Radix UI primitive and applies Tailwind classes via Class Variance Authority (CVA). The styling contract is a shared cn() helper that merges clsx conditional classes with tailwind-merge conflict resolution, so <Button className="bg-red-500"> correctly overrides the default background instead of producing two conflicting classes. Every component you install follows this same pattern, which is why shadcn/ui reads as a single system rather than a bag of unrelated widgets.

How do I install shadcn/ui in a Next.js 16 project?

Start with a fresh Next.js 16 app (App Router, TypeScript, Tailwind CSS v4 selected during scaffolding), then run the shadcn init command. The 2026 CLI auto-detects your Tailwind version and writes the correct @theme block for v4, so you don't need to configure PostCSS manually anymore.

# 1. Create the app (accept App Router, TypeScript, Tailwind, no src/ dir)
npx create-next-app@latest my-app

cd my-app

# 2. Initialize shadcn/ui
npx shadcn@latest init

# The wizard will ask:
#   - Which style? -> new-york (or default)
#   - Which base color? -> zinc
#   - Where is your global CSS file? -> app/globals.css
#   - Configure CSS variables for theming? -> yes
#   - Where is your tailwind.config? -> (none needed for v4)
#   - Configure import aliases? -> yes (@/components, @/lib/utils)

# 3. Add your first components
npx shadcn@latest add button card dialog form input

After init, three things exist that didn't before: a components.json config at the project root (this is what the CLI reads on subsequent add calls), a lib/utils.ts exporting the cn() helper, and an updated app/globals.css containing the @theme inline block with your OKLCH color tokens. The Tailwind v4 zero-config pipeline reads that CSS file directly, so there's no separate tailwind.config.ts unless you opt in.

// lib/utils.ts - the shared cn() helper every component uses
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Once init finishes, calling npx shadcn@latest add <name> writes new components into components/ui/. Each add command resolves peer dependencies (like @radix-ui/react-dialog) and installs them into package.json automatically. If you want to preview what will be added without touching disk, pass --dry-run.

Configuring Tailwind v4 theming and CSS tokens

In Tailwind CSS v4, theme tokens live in your CSS file instead of a JavaScript config. The shadcn CLI writes an @theme inline block that declares your color palette using OKLCH, a perceptually uniform color space that gives you consistent lightness across hues (which matters a lot for hover and disabled states). If you're coming from HSL or hex, the migration is mechanical, and OKLCH ships in every modern browser as of 2026. For a deeper dive on the CSS-first setup, see the Tailwind v4 with Next.js 16 CSS-first setup guide.

/* app/globals.css - trimmed excerpt from what shadcn init produces */
@import "tailwindcss";
@import "tw-animate-css";

@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);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --border: oklch(0.922 0 0);
  --radius: 0.625rem;
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --primary: oklch(0.985 0 0);
  --primary-foreground: oklch(0.205 0 0);
  --muted: oklch(0.269 0 0);
  --muted-foreground: oklch(0.708 0 0);
  --border: oklch(0.269 0 0);
}

@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);
  --radius-lg: var(--radius);
  --radius-md: calc(var(--radius) - 2px);
  --radius-sm: calc(var(--radius) - 4px);
}

The two-layer pattern (CSS custom properties in :root, then re-exposed to Tailwind through @theme inline) is what makes the bg-primary, text-muted-foreground, and border-border utility classes work. To rebrand the app, edit the OKLCH values in :root; you never touch component source. To add a semantic color like --color-success, add both the CSS variable and the @theme inline mapping, and the utility bg-success becomes available everywhere immediately.

How do I add dark mode with shadcn/ui and next-themes?

The CSS variables above already handle both themes. The missing piece is a mechanism to toggle the .dark class on <html>. shadcn/ui delegates this to next-themes, which handles the tricky parts: reading system preference, persisting the choice to localStorage, and (critically) injecting a synchronous script into the document <head> so the initial paint doesn't flash the wrong theme.

pnpm add next-themes
// components/theme-provider.tsx
"use client"

import { ThemeProvider as NextThemesProvider } from "next-themes"
import type { ComponentProps } from "react"

export function ThemeProvider({
  children,
  ...props
}: ComponentProps<typeof NextThemesProvider>) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
// app/layout.tsx - Server Component root layout
import { ThemeProvider } from "@/components/theme-provider"
import "./globals.css"

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}

Two details that trip up almost every first-time integrator. First, suppressHydrationWarning on <html> is required because the theme class is written by client JavaScript before hydration, so the server-rendered HTML will legitimately differ from the client's first render. Second, disableTransitionOnChange stops CSS transitions from firing during the toggle, which otherwise produces a distracting fade-through effect on every color-bound element. I hit this exact bug shipping a client project last year and spent an afternoon convinced my CSS was broken. A minimal theme toggle button using shadcn primitives looks like this:

// components/theme-toggle.tsx
"use client"

import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"

export function ThemeToggle() {
  const { setTheme, resolvedTheme } = useTheme()

  return (
    <Button
      variant="ghost"
      size="icon"
      onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
      aria-label="Toggle theme"
    >
      <Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
      <Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
    </Button>
  )
}

Does shadcn/ui work with React Server Components?

Yes, and this is where a lot of team-shared UI libraries fall apart. Every shadcn/ui component that doesn't call a React hook or a browser API renders as a Server Component by default. Card, CardHeader, Badge, Separator, Avatar, Skeleton, Alert, and the various typography helpers all ship without a "use client" directive. Only components that need interactivity (Dialog, DropdownMenu, Sheet, Popover, Toast, Command, and the accordion/tabs pair) mark themselves as client components, because their underlying Radix primitives use hooks like useState and useLayoutEffect.

What matters in practice is the boundary. A Server Component page can render a Card containing a DropdownMenu: the DropdownMenu becomes a client "island" while the surrounding markup remains server-rendered. The bundle savings are real. On a typical dashboard, roughly 60% of the shadcn surface stays on the server. The one caveat is Form, which imports React Hook Form and is therefore client-only. If you're pairing shadcn Form with a mutation, my recommended pattern is a client wrapper that calls a Server Action. See the React Hook Form + Zod + Server Actions guide for the full end-to-end pattern.

// app/dashboard/page.tsx - Server Component page
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { db } from "@/db"
import { UserMenu } from "./user-menu" // "use client" component

export default async function Dashboard() {
  const stats = await db.query.stats.findMany() // runs on server

  return (
    <section className="grid gap-4 md:grid-cols-3">
      {stats.map((s) => (
        <Card key={s.id}>
          <CardHeader className="flex flex-row items-center justify-between">
            <CardTitle>{s.label}</CardTitle>
            <Badge variant="secondary">{s.trend}</Badge>
          </CardHeader>
          <CardContent>
            <p className="text-2xl font-semibold">{s.value}</p>
            {/* client island for interactivity */}
            <UserMenu statId={s.id} />
          </CardContent>
        </Card>
      ))}
    </section>
  )
}

Building a private component registry

The 2026 release of shadcn/ui made custom registries a first-class feature. A registry is a set of JSON files that describe components, their dependencies, and their source, served from any HTTPS URL. Teams use this to distribute internal design-system components across multiple Next.js apps without publishing an npm package. Point the CLI at your registry with npx shadcn@latest add https://ui.acme.com/r/data-table.json and the component lands in the consuming app the same way as an official one. Full spec is on the shadcn registry documentation.

// registry/company-metric-card.json
{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "company-metric-card",
  "type": "registry:ui",
  "dependencies": ["lucide-react"],
  "registryDependencies": ["card", "badge"],
  "files": [
    {
      "path": "company-metric-card.tsx",
      "type": "registry:ui",
      "target": "components/ui/company-metric-card.tsx",
      "content": "import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'\n// ...component source here..."
    }
  ],
  "cssVars": {
    "light": { "brand": "oklch(0.6 0.2 260)" },
    "dark":  { "brand": "oklch(0.75 0.2 260)" }
  }
}

Registries can host both raw components and full "blocks" (composed layouts like a settings page or an auth screen). Because the schema declares registryDependencies, running add company-metric-card will automatically install card and badge first if they're not already present. Honestly, this is the killer feature for larger teams: your design-system components can safely depend on shadcn primitives without duplicating them into every consuming app.

Using shadcn/ui in a Turborepo monorepo

The 2026 CLI added first-class monorepo support via the --src-dir flag and workspace-aware components.json. The idiomatic setup is a packages/ui workspace that owns the component source, with each app importing from @acme/ui. This is the same layout used by Vercel's own reference apps. If you haven't set up the workspace yet, the Turborepo + pnpm workspaces guide walks through the base configuration.

# From the monorepo root
pnpm dlx shadcn@latest init --cwd packages/ui

# Add a component to the shared package
pnpm dlx shadcn@latest add button --cwd packages/ui

# In apps/web/package.json
{
  "dependencies": {
    "@acme/ui": "workspace:*"
  }
}
// apps/web/app/page.tsx
import { Button } from "@acme/ui/button"

export default function Home() {
  return <Button>Ship it</Button>
}

Two configuration pieces the CLI won't do for you. First, the shared packages/ui/package.json needs "exports" entries that map each component name to its file. Second, the consuming app's Tailwind CSS import must include @source "../../packages/ui/src/**/*.{ts,tsx}"; so Tailwind v4 scans the shared workspace for class usage. Miss the second one and your production build ships components with unstyled class names. It's an easy 30-minute debugging session, and I've fallen into it more than once.

Common pitfalls and troubleshooting

Hydration mismatch on first paint. Almost always caused by next-themes without suppressHydrationWarning on the <html> tag. React 19 is stricter about hydration diffs and will unmount the entire tree on mismatch, causing a visible flicker. The Next.js client boundaries documentation covers the underlying rules.

Utility classes not applying in production. Tailwind v4 uses content detection via @source globs in your CSS. If a shadcn component uses a class you never use elsewhere and the file isn't inside the default scan path (say, a shared monorepo package), Tailwind will tree-shake it out. Add an explicit @source "../../packages/ui/**/*.{ts,tsx}" to your CSS entry.

Radix Portal escapes the theme. Portaled components (Dialog, Toast, Popover) render outside your app tree, so a theme scoped to a subtree won't reach them. Either put the theme class on <html> (the shadcn default) or wrap the portal with a ThemeProvider using the forcedTheme prop.

Component updates don't propagate. Because you own the source, re-running add button will not overwrite your changes by default. Use --overwrite to pull upstream updates, then use git diff to reconcile your customizations. That's the shadcn trade-off: you get freedom at the cost of automatic upgrades.

Frequently Asked Questions

Is shadcn/ui free for commercial use?

Yes. shadcn/ui is MIT-licensed, and because the source is copied into your repo you own that code outright. There's no runtime dependency on a shadcn npm package and no license fee for any team size or commercial use case.

What's the difference between shadcn/ui and Material UI or Chakra?

MUI and Chakra ship as versioned npm packages you consume through imports, so the component internals are opaque. shadcn/ui copies source into your repo, meaning you edit component internals directly. That means no upstream API breakage risk, but also no free upgrades: pulling a newer button.tsx from the registry requires a manual diff.

Do I need Tailwind CSS to use shadcn/ui?

Yes. Component source uses Tailwind utility classes and the cn() merge helper. As of the 2026 releases the CLI targets Tailwind v4 by default; v3 is still supported for legacy projects, but v4's CSS-first theming is much better matched to the shadcn token model.

Can I use shadcn/ui components as Server Components?

Most of them, yes. Layout and presentational primitives (Card, Badge, Separator, Skeleton, Alert) have no "use client" directive and render on the server. Interactive components that use Radix hooks (Dialog, DropdownMenu, Sheet, Popover, Tabs) are client-only and become bundled islands inside a Server Component page.

How do I update a shadcn/ui component after I've customized it?

Run npx shadcn@latest diff <component> to see what changed upstream, then either add --overwrite and re-apply your customizations from git, or manually port the interesting parts. There's no automated three-way merge; this is the explicit trade-off of the "own your components" model.

Editorial Team
About the Author Editorial Team

Our team of expert writers and editors.