Complete Guide to shadcn/ui with Next.js: Building Modern Interfaces

Introduction
shadcn/ui has become the go-to solution for building modern interfaces with React and Next.js. Unlike traditional component libraries like Material UI or Chakra UI, shadcn/ui takes a radically different approach: instead of installing an npm package, you copy components directly into your project. This gives you full control over the code, styling, and behavior of every component.
In this tutorial, you will learn how to set up shadcn/ui in a Next.js project, customize the theme, build complex components like forms and data tables, and apply best practices for creating professional-grade applications.
What You Will Learn
- Install and configure shadcn/ui in a Next.js project
- Use the most common components (Button, Card, Dialog, Form)
- Customize the theme with CSS variables
- Build a complete form with validation using React Hook Form and Zod
- Create an interactive data table with sorting and filtering
- Implement dark mode
- Organize your components in a maintainable way
Prerequisites
Before starting, make sure you have:
- Node.js 18+ installed on your machine
- Basic knowledge of React and TypeScript
- Familiarity with Tailwind CSS
- A code editor (VS Code recommended)
Step 1: Create the Next.js Project
Start by creating a new Next.js project with TypeScript and Tailwind CSS:
npx create-next-app@latest my-shadcn-app --typescript --tailwind --eslint --app --src-dir
cd my-shadcn-appSelect these options during setup:
- Would you like to use App Router? Yes
- Would you like to customize the default import alias? Yes, use
@/*
Step 2: Install shadcn/ui
Run the shadcn/ui initialization command:
npx shadcn@latest initThe setup wizard will ask several questions:
Which style would you like to use? › New York
Which color would you like to use as base color? › Neutral
Do you want to use CSS variables for colors? › yes
This command will:
- Create a
components.jsonfile at the project root - Add necessary utilities in
lib/utils.ts - Configure CSS variables in your
globals.cssfile - Update
tailwind.config.tswith component paths
Let's examine the generated components.json:
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}Step 3: Add Your First Components
shadcn/ui provides a CLI to add components individually. Let's add the essentials:
npx shadcn@latest add button card input labelEach component is copied into src/components/ui/. Let's look at the Button component:
// src/components/ui/button.tsx
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 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
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 }The pattern is clear: each component uses cva (Class Variance Authority) to manage variants and cn (a wrapper around clsx and tailwind-merge) to merge CSS classes.
Step 4: Build a Page with Components
Let's modify the home page to use our components:
// src/app/page.tsx
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
export default function Home() {
return (
<main className="container mx-auto py-10">
<h1 className="text-4xl font-bold mb-8">
My shadcn/ui Application
</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<CardHeader>
<CardTitle>Accessible Components</CardTitle>
<CardDescription>
Built on Radix UI, all components are
accessible by default.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Keyboard navigation, screen readers, and
ARIA compliance built in.
</p>
</CardContent>
<CardFooter>
<Button>Learn More</Button>
</CardFooter>
</Card>
<Card>
<CardHeader>
<CardTitle>Customizable</CardTitle>
<CardDescription>
The code lives in your project. Modify
everything to suit your needs.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
CSS variables, Tailwind, and fully
editable components.
</p>
</CardContent>
<CardFooter>
<Button variant="outline">Explore</Button>
</CardFooter>
</Card>
<Card>
<CardHeader>
<CardTitle>Native TypeScript</CardTitle>
<CardDescription>
Strict types and autocomplete for an
optimal developer experience.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Every component is fully typed with
well-defined props.
</p>
</CardContent>
<CardFooter>
<Button variant="secondary">Discover</Button>
</CardFooter>
</Card>
</div>
</main>
)
}Start the development server to see the result:
npm run devStep 5: Customize the Theme
The shadcn/ui theming system is based on CSS variables. Open src/app/globals.css to see the generated variables:
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
/* ... other dark variables */
}
}To create a custom theme, modify these variables. For example, a professional blue theme:
:root {
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--radius: 0.75rem;
}You can also use the shadcn/ui online theme generator to visually create your color palette and copy the generated CSS variables.
Step 6: Form with Validation
One of the most common use cases is building forms. shadcn/ui integrates seamlessly with React Hook Form and Zod for validation.
Install the necessary dependencies:
npx shadcn@latest add form select textarea toast
npm install zodCreate a complete contact form:
// src/components/contact-form.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"
import { Textarea } from "@/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useToast } from "@/hooks/use-toast"
const formSchema = z.object({
name: z
.string()
.min(2, "Name must be at least 2 characters"),
email: z
.string()
.email("Invalid email address"),
subject: z
.string()
.min(1, "Please select a subject"),
message: z
.string()
.min(10, "Message must be at least 10 characters")
.max(500, "Message must not exceed 500 characters"),
})
type FormValues = z.infer<typeof formSchema>
export function ContactForm() {
const { toast } = useToast()
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
email: "",
subject: "",
message: "",
},
})
function onSubmit(values: FormValues) {
console.log(values)
toast({
title: "Message sent!",
description: "We will get back to you within 24 hours.",
})
form.reset()
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input placeholder="John Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="john@example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel>Subject</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Choose a subject" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="general">
General Inquiry
</SelectItem>
<SelectItem value="support">
Technical Support
</SelectItem>
<SelectItem value="sales">
Sales Inquiry
</SelectItem>
<SelectItem value="partnership">
Partnership
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel>Message</FormLabel>
<FormControl>
<Textarea
placeholder="Describe your request..."
className="min-h-[120px]"
{...field}
/>
</FormControl>
<FormDescription>
Between 10 and 500 characters.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
Send Message
</Button>
</form>
</Form>
)
}This form provides:
- Client-side validation with clear error messages
- TypeScript types automatically inferred from the Zod schema
- Accessibility: associated labels, error messages linked to fields
- User feedback via toast notifications
Step 7: Interactive Data Table
Data tables are another frequently used component. shadcn/ui provides a Table component that integrates with TanStack Table for sorting, filtering, and pagination.
npx shadcn@latest add table badge dropdown-menu
npm install @tanstack/react-tableCreate a data table to display a list of users:
// src/components/users-table.tsx
"use client"
import { useState } from "react"
import {
ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
SortingState,
useReactTable,
} from "@tanstack/react-table"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ArrowUpDown } from "lucide-react"
type User = {
id: string
name: string
email: string
role: "admin" | "editor" | "viewer"
status: "active" | "inactive"
}
const data: User[] = [
{
id: "1",
name: "Alice Martin",
email: "alice@example.com",
role: "admin",
status: "active",
},
{
id: "2",
name: "Bob Johnson",
email: "bob@example.com",
role: "editor",
status: "active",
},
{
id: "3",
name: "Claire Williams",
email: "claire@example.com",
role: "viewer",
status: "inactive",
},
]
const columns: ColumnDef<User>[] = [
{
accessorKey: "name",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
},
{
accessorKey: "email",
header: "Email",
},
{
accessorKey: "role",
header: "Role",
cell: ({ row }) => {
const role = row.getValue("role") as string
const variant =
role === "admin"
? "default"
: role === "editor"
? "secondary"
: "outline"
return <Badge variant={variant}>{role}</Badge>
},
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.getValue("status") as string
return (
<Badge
variant={status === "active" ? "default" : "destructive"}
>
{status}
</Badge>
)
},
},
]
export function UsersTable() {
const [sorting, setSorting] = useState<SortingState>([])
const [globalFilter, setGlobalFilter] = useState("")
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
state: { sorting, globalFilter },
})
return (
<div className="space-y-4">
<Input
placeholder="Search users..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-sm"
/>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
)
}Step 8: Implement Dark Mode
shadcn/ui natively supports dark mode through CSS variables. Install next-themes to manage the toggle:
npm install next-themes
npx shadcn@latest add dropdown-menuCreate a theme provider:
// src/components/theme-provider.tsx
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return (
<NextThemesProvider {...props}>
{children}
</NextThemesProvider>
)
}Add it to the root layout:
// src/app/layout.tsx
import { ThemeProvider } from "@/components/theme-provider"
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>
)
}Create a theme toggle button:
// src/components/theme-toggle.tsx
"use client"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function ThemeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}Step 9: Dialog and Sheet Components
Modals and side panels are essential in modern applications:
npx shadcn@latest add dialog sheet alert-dialogExample confirmation dialog:
// src/components/confirm-dialog.tsx
"use client"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
interface ConfirmDialogProps {
title: string
description: string
onConfirm: () => void
children: React.ReactNode
}
export function ConfirmDialog({
title,
description,
onConfirm,
children,
}: ConfirmDialogProps) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>
{children}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>
{description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}Step 10: Organize Your Components
To maintain a clean project, adopt this structure:
src/
├── components/
│ ├── ui/ # shadcn/ui components (generated)
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── dialog.tsx
│ │ └── ...
│ ├── forms/ # Composed forms
│ │ ├── contact-form.tsx
│ │ └── login-form.tsx
│ ├── tables/ # Data tables
│ │ └── users-table.tsx
│ ├── layout/ # Navigation, Footer, etc.
│ │ ├── header.tsx
│ │ ├── sidebar.tsx
│ │ └── footer.tsx
│ └── shared/ # Custom reusable components
│ ├── confirm-dialog.tsx
│ └── theme-toggle.tsx
├── hooks/ # Custom hooks
│ └── use-toast.ts
└── lib/
└── utils.ts # cn() utility
Organization rules:
- Never modify files in
ui/for use-case-specific changes. Create a wrapper inshared/instead - Prefix composed components with their context:
ContactForm,UsersTable - Export from index files if you have many components in a folder
Best Practices
1. Always Use the cn() Function
import { cn } from "@/lib/utils"
function MyComponent({ className }: { className?: string }) {
return (
<div className={cn("p-4 rounded-lg", className)}>
Content
</div>
)
}2. Composition Over Configuration
// Prefer composition
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
</CardHeader>
<CardContent>Content</CardContent>
</Card>
// Rather than complex props
// <Card title="Title" content="Content" /> // Avoid this3. Built-in Accessibility
shadcn/ui is built on Radix UI, which automatically handles:
- Keyboard navigation
- ARIA attributes
- Focus trapping in modals
- Screen reader announcements
Always include labels for form fields and alternative text for visual elements.
4. Performance with React Server Components
shadcn/ui works with React Server Components. Components that do not require interactivity can remain as server components:
// This can be a Server Component
// No need for "use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
export function StatCard({ title, value }: { title: string; value: string }) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">{title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{value}</p>
</CardContent>
</Card>
)
}Troubleshooting
Component Not Rendering Correctly
Check that tailwind.config.ts includes the component paths:
content: [
"./src/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
],"Module not found" Error After Adding a Component
Restart the development server. Sometimes new files are not detected by hot reload:
# Ctrl+C then
npm run devDark Mode Styles Not Working
Make sure darkMode: "class" is in your tailwind.config.ts and that the ThemeProvider wraps your application.
Next Steps
Now that you have mastered the basics of shadcn/ui with Next.js, here is how to go further:
- Explore more components: shadcn/ui offers over 40 components (Accordion, Calendar, Combobox, Command palette, etc.)
- Create a Design System: use CSS variables and variants to create a consistent visual identity
- Integrate with an API: connect your forms and tables to a backend API with TanStack Query
- Add animations: use Framer Motion for smooth state transitions
Conclusion
shadcn/ui represents a major evolution in how we build interfaces with React. By giving you full ownership of the code, it eliminates the constraints of traditional component libraries while providing a solid, accessible, and customizable foundation.
Key takeaways:
- Code ownership: you control every aspect of your components
- Native accessibility: Radix UI handles complex interactions
- Flexible theming: CSS variables enable total customization
- TypeScript integration: strict types for a better developer experience
- Rich ecosystem: React Hook Form, Zod, TanStack Table integrate naturally
With this foundation, you are ready to build professional applications with high-quality user interfaces.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build a Real-Time Full-Stack App with Convex and Next.js 15
Learn how to build a real-time full-stack application using Convex and Next.js 15. This tutorial covers schema design, queries, mutations, real-time subscriptions, authentication, and file uploads — all with end-to-end type safety.

Authenticate Your Next.js 15 App with Auth.js v5: Email, OAuth, and Role-Based Access
Learn how to add production-ready authentication to your Next.js 15 application using Auth.js v5. This comprehensive guide covers Google OAuth, email/password credentials, protected routes, middleware, and role-based access control.

Next.js Internationalization with next-intl — Complete App Router i18n Guide
Build a fully internationalized Next.js application with next-intl. This comprehensive guide covers App Router setup, RTL language support, dynamic routing, pluralization, and production-ready i18n patterns.