shadcn/ui Tutorial: Complete Beginner's Guide to Setup and InstallationGet started with shadcn/ui in your Next.js project in under 10 minutes

Introduction: Why shadcn/ui Changes Everything

Let's be honest—component libraries have been a mess for years. You install some bloated package, get locked into their design system, and end up fighting the framework more than building features. Then shadcn/ui came along and flipped the entire model on its head. It's not a component library in the traditional sense. You don't npm install it and import components from node_modules. Instead, it's a collection of beautifully designed, accessible components that you copy directly into your project. This means you own the code, you control the styling, and there's zero runtime overhead from an external dependency. It's built on Radix UI primitives for accessibility and uses Tailwind CSS for styling, giving you the perfect balance of functionality and customization.

The beauty of this approach is brutal simplicity. When you need to change how a button behaves or looks, you just edit the file in your codebase. No more wrestling with CSS specificity or trying to override some deeply nested component prop. The components are yours. This guide will walk you through setting up shadcn/ui in a Next.js project from scratch, and I promise you'll have functional, beautiful components running in less than 10 minutes. We'll cover everything from initial installation to adding your first components, plus the gotchas that'll save you hours of debugging. Whether you're building a SaaS dashboard or a marketing site, shadcn/ui gives you production-ready components without the usual headaches.

Prerequisites: What You Need Before Starting

Before diving in, you need a Next.js project with the App Router (Next.js 13+). If you're still on the Pages Router, this will work, but you'll miss out on some React Server Component optimizations. Your project also needs Tailwind CSS configured—shadcn/ui is built entirely on Tailwind utilities, so this isn't optional. If you're starting fresh, create a new Next.js project with npx create-next-app@latest and make sure to select "Yes" when asked about Tailwind CSS. You'll also want Node.js 16.8 or later installed.

Here's the reality: if you try to add shadcn/ui to a project that already has a different component library or design system, you're going to have conflicts. The Tailwind configuration that shadcn/ui adds uses CSS variables for theming, and if you've already got a custom theme setup, you'll need to merge them carefully. It's not impossible, but it's easier to start with shadcn/ui from the beginning or commit to ripping out your old component system completely. Also, if you're not comfortable with TypeScript, you might struggle a bit—all shadcn/ui components are written in TypeScript, though you can technically strip the types and use JavaScript.

Initial Setup: Installing the shadcn/ui CLI

The entire shadcn/ui workflow revolves around a CLI tool that handles component installation and configuration. First, navigate to your Next.js project directory in your terminal. Then run the initialization command:

npx shadcn@latest init

This command kicks off an interactive setup that asks you several questions. It'll detect that you're using Next.js and Tailwind, then ask where you want to store components (default is components/ui), which style you prefer (Default or New York—honestly, just pick Default unless you want tighter spacing), what color scheme you want (Slate, Gray, Zinc, etc.), and whether you want CSS variables for colors (say yes). The CLI is smart enough to configure everything automatically, including updating your tailwind.config.ts and creating a components.json file that tracks your setup.

Here's what the CLI actually does behind the scenes: it adds the necessary Tailwind plugins, sets up your content paths, creates utility functions in lib/utils.ts for className merging, and configures path aliases in your tsconfig.json. The components.json file becomes your source of truth for shadcn/ui configuration. If you're working in a team, commit this file so everyone has consistent component installations.

// Example components.json generated by init
{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "default",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "config": "tailwind.config.ts",
    "css": "app/globals.css",
    "baseColor": "slate",
    "cssVariables": true
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils"
  }
}

One thing that trips people up: if the CLI can't automatically detect your setup, it'll ask you to manually specify paths. Make sure your globals.css path is correct—this is where Tailwind directives and shadcn/ui CSS variables live. If you've customized your project structure, you might need to adjust paths in components.json after initialization.

Adding Components: Your First shadcn/ui Component

Once initialization is complete, adding components is ridiculously easy. Let's add a Button component as our first example:

npx shadcn@latest add button

This downloads the Button component code directly into your components/ui/button.tsx file. Open it up and you'll see a fully-typed React component using Radix UI primitives and Tailwind classes. The component uses class-variance-authority (CVA) for managing variants—this is a pattern library for creating type-safe component variants with Tailwind.

// components/ui/button.tsx (simplified example)
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
        secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 rounded-md px-3",
        lg: "h-11 rounded-md px-8",
        icon: "h-10 w-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button"
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    )
  }
)
Button.displayName = "Button"

export { Button, buttonVariants }

Now you can use this Button anywhere in your app:

// app/page.tsx
import { Button } from "@/components/ui/button"

export default function Home() {
  return (
    <div className="p-8">
      <Button>Click me</Button>
      <Button variant="outline">Outline</Button>
      <Button variant="destructive" size="lg">Delete</Button>
    </div>
  )
}

The real power here is that this is your code now. Want to add a new variant? Edit buttonVariants. Need to change the default radius? Modify the Tailwind classes. You're not fighting an abstraction—you're just editing React components in your own codebase. This is why shadcn/ui is so popular: it gives you a head start with beautiful, accessible components, but doesn't lock you into someone else's decisions.

Configuration Deep Dive: Tailwind and CSS Variables

The magic of shadcn/ui's theming system comes from CSS variables in your globals.css. During initialization, the CLI adds a bunch of custom properties that define your color scheme. Here's what it looks like:

/* app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    --card: 0 0% 100%;
    --card-foreground: 222.2 84% 4.9%;
    --popover: 0 0% 100%;
    --popover-foreground: 222.2 84% 4.9%;
    --primary: 222.2 47.4% 11.2%;
    --primary-foreground: 210 40% 98%;
    --secondary: 210 40% 96.1%;
    --secondary-foreground: 222.2 47.4% 11.2%;
    --muted: 210 40% 96.1%;
    --muted-foreground: 215.4 16.3% 46.9%;
    --accent: 210 40% 96.1%;
    --accent-foreground: 222.2 47.4% 11.2%;
    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 210 40% 98%;
    --border: 214.3 31.8% 91.4%;
    --input: 214.3 31.8% 91.4%;
    --ring: 222.2 84% 4.9%;
    --radius: 0.5rem;
  }

  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    /* ... dark mode colors */
  }
}

These values use HSL color space without the hsl() wrapper—this is intentional for Tailwind's opacity modifiers to work correctly. Your tailwind.config.ts is configured to read these variables:

// tailwind.config.ts
import type { Config } from "tailwindcss"

const config = {
  darkMode: ["class"],
  content: [
    './pages/**/*.{ts,tsx}',
    './components/**/*.{ts,tsx}',
    './app/**/*.{ts,tsx}',
    './src/**/*.{ts,tsx}',
  ],
  theme: {
    container: {
      center: true,
      padding: "2rem",
      screens: {
        "2xl": "1400px",
      },
    },
    extend: {
      colors: {
        border: "hsl(var(--border))",
        input: "hsl(var(--input))",
        ring: "hsl(var(--ring))",
        background: "hsl(var(--background))",
        foreground: "hsl(var(--foreground))",
        primary: {
          DEFAULT: "hsl(var(--primary))",
          foreground: "hsl(var(--primary-foreground))",
        },
        secondary: {
          DEFAULT: "hsl(var(--secondary))",
          foreground: "hsl(var(--secondary-foreground))",
        },
        // ... rest of color definitions
      },
      borderRadius: {
        lg: "var(--radius)",
        md: "calc(var(--radius) - 2px)",
        sm: "calc(var(--radius) - 4px)",
      },
    },
  },
  plugins: [require("tailwindcss-animate")],
} satisfies Config

export default config

Here's the brutal truth: this setup is both brilliant and occasionally frustrating. It's brilliant because you can theme your entire app by changing CSS variables. Want a dark mode? Just toggle a class on your root element and the .dark styles take over. Want different themes for different sections? Define new CSS variable scopes. But it's frustrating if you're used to traditional Tailwind where bg-blue-500 gives you a specific color—now colors are semantic (primary, secondary) rather than literal (blue, red). You need to think in terms of your design system, not raw colors.

Common Components and Usage Patterns

Let's be practical and look at components you'll actually use. After Button, you'll probably want Form components for handling user input. shadcn/ui's form system uses React Hook Form and Zod for validation—this is non-negotiable and honestly, it's the right choice. Here's how to add form components:

npx shadcn@latest add form input label

This installs Form, Input, and Label components. Here's a real-world example of a login form:

// app/login/page.tsx
"use client"

import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"

const formSchema = z.object({
  email: z.string().email("Invalid email address"),
  password: z.string().min(8, "Password must be at least 8 characters"),
})

export default function LoginPage() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      email: "",
      password: "",
    },
  })

  function onSubmit(values: z.infer<typeof formSchema>) {
    console.log(values)
    // Handle login logic
  }

  return (
    <div className="max-w-md mx-auto p-8">
      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
          <FormField
            control={form.control}
            name="email"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Email</FormLabel>
                <FormControl>
                  <Input placeholder="you@example.com" {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <FormField
            control={form.control}
            name="password"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Password</FormLabel>
                <FormControl>
                  <Input type="password" {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <Button type="submit" className="w-full">Login</Button>
        </form>
      </Form>
    </div>
  )
}

The Form component pattern feels verbose at first—all those FormField, FormItem, FormLabel wrappers seem excessive. But this structure gives you consistent spacing, automatic error handling, and accessibility for free. The validation errors from Zod automatically appear below inputs, styled correctly. This is production-quality code that handles edge cases you'd otherwise forget.

Other essential components to add: dialog for modals, dropdown-menu for context menus, toast for notifications, table for data display, and card for content containers. Don't add everything at once—shadcn/ui's philosophy is to only include what you need. Each component you add increases your bundle size slightly (though it's minimal since there's no external library), and more importantly, adds code you need to maintain.

Customization and Best Practices

Now that you've got components installed, let's talk about customization—because this is where shadcn/ui shines. Since all components live in your codebase, you can modify them however you want. Want all your buttons to have a subtle animation? Edit the button component's base classes. Need a custom select variant? Add it to the variants object. But here are some patterns to follow so you don't create a maintenance nightmare.

First, avoid editing the core component files for project-specific changes. Instead, create wrapper components. For example, if you need a LoadingButton that shows a spinner, don't edit button.tsx. Create a new file:

// components/loading-button.tsx
import { Button, ButtonProps } from "@/components/ui/button"
import { Loader2 } from "lucide-react"

interface LoadingButtonProps extends ButtonProps {
  loading?: boolean
}

export function LoadingButton({ 
  loading, 
  children, 
  disabled,
  ...props 
}: LoadingButtonProps) {
  return (
    <Button disabled={loading || disabled} {...props}>
      {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
      {children}
    </Button>
  )
}

This approach keeps the base Button component pristine while extending functionality. If shadcn/ui updates the Button component, you can safely update it without losing your customizations. Second, use the cn() utility function religiously. This is the className merger from lib/utils.ts:

// lib/utils.ts
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"

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

This function combines class names intelligently, resolving Tailwind conflicts. Always use it when adding className props: className={cn("your-classes", className)}. This lets consumers override your styles properly. Third, respect the semantic color system. Don't hardcode bg-blue-500 in components—use bg-primary or bg-accent. This keeps your design system consistent and makes theming trivial.

For global changes like font families or spacing, modify your tailwind.config.ts extend object. Want to use Inter font everywhere? Add it to the theme:

theme: {
  extend: {
    fontFamily: {
      sans: ["var(--font-inter)", ...fontFamily.sans],
    },
    // ... rest of config
  }
}

Finally, the controversial take: don't get too creative with component variants. shadcn/ui gives you default, outline, ghost, etc. These cover 95% of use cases. Adding 10 custom variants makes your codebase harder to navigate and increases decision paralysis for other developers. When in doubt, compose components rather than adding variants.

Conclusion: Is shadcn/ui Worth It?

After setting up dozens of projects with shadcn/ui, here's the honest assessment: it's the best component approach for Next.js applications right now, but it's not perfect. The ownership model—where you control all component code—is liberating once you adjust to it. You're not debugging some abstraction in node_modules or fighting CSS specificity battles. You just edit files in your project. The components are well-designed, accessible by default (thanks to Radix UI), and look professional without customization. For solo developers and small teams, shadcn/ui dramatically speeds up development.

But there are tradeoffs. When shadcn/ui updates components, you don't get those updates automatically. You need to manually re-run the add command and merge changes into your modified versions. This can be tedious if you've heavily customized components. The Tailwind + CSS variable approach is clever but has a learning curve—you need to understand how the theming system works to make effective changes. And if you're not using TypeScript, you lose significant value since type safety is a core feature. The documentation is generally good, but some complex components like Data Table require reading source code to fully understand.

Despite these issues, shadcn/ui has fundamentally changed how I build React applications. The velocity you get from having production-ready components that you can customize without friction is incredible. The ecosystem is growing—there are community sites adding new components, themes, and templates. If you're starting a new Next.js project and not using a design system like Material UI or Chakra for specific reasons, shadcn/ui should be your default choice. Just remember: you're signing up to maintain components in your codebase, not just consume a library. Treat them like first-class citizens of your project, version control them properly, and they'll serve you well. Now stop reading and go build something—you've got all the knowledge you need to get started.