shadcn/ui in Next.js 14: The Complete Setup and Usage Guide
shadcn/ui has become the default UI layer for most new Next.js apps. Unlike traditional component libraries, it's not a package you install — it copies components directly into your codebase. Here's how to set it up correctly and use it effectively.
Why shadcn/ui
You own the code. Components live in components/ui/ in your repo. You modify them directly — no fighting with third-party styles or overriding CSS.
Tailwind-native. Every component is built with Tailwind classes. No CSS-in-JS, no runtime style injection.
Radix UI primitives. Accessible, keyboard-navigable, handles ARIA correctly out of the box.
TypeScript throughout. Every component is typed. Works seamlessly with Next.js App Router.
Setup
npx shadcn@latest init
The CLI asks:
- Style: Default or New York (New York is more refined, most people pick it)
- Base color: Slate, Gray, Stone, etc. (sets your neutral palette)
- CSS variables: Yes (required for dark mode support)
This creates components.json (config) and sets up tailwind.config.ts and globals.css with CSS variable definitions.
Install Components
# Install individual components as needed
npx shadcn@latest add button
npx shadcn@latest add input
npx shadcn@latest add dialog
npx shadcn@latest add form
# Or install multiple at once
npx shadcn@latest add button input form card badge
Components install to components/ui/. You can edit them directly.
Core Components for an AI SaaS
For a standard dashboard + AI chat app, you'll use:
npx shadcn@latest add button input textarea label card badge
npx shadcn@latest add dialog sheet dropdown-menu
npx shadcn@latest add avatar separator skeleton
npx shadcn@latest add toast sonner
npx shadcn@latest add form
Common Patterns
Form with Validation
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
const schema = z.object({
email: z.string().email("Invalid email"),
name: z.string().min(2, "Name must be at least 2 characters"),
});
type FormValues = z.infer<typeof schema>;
export function ProfileForm() {
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { email: "", name: "" },
});
async function onSubmit(values: FormValues) {
await fetch("/api/profile", {
method: "POST",
body: JSON.stringify(values),
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Your name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="you@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Saving..." : "Save"}
</Button>
</form>
</Form>
);
}
Confirmation Dialog
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export function DeleteConfirmDialog({ onConfirm }: { onConfirm: () => void }) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete Account</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. Your account and all data will be permanently deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline">Cancel</Button>
<Button variant="destructive" onClick={onConfirm}>
Delete Account
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Loading Skeleton
import { Skeleton } from "@/components/ui/skeleton";
export function DashboardSkeleton() {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="p-4 border rounded-lg space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-16" />
</div>
))}
</div>
</div>
);
}
Dark Mode
shadcn/ui supports dark mode via CSS variables and Tailwind's dark: prefix.
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>
{children}
</ThemeProvider>
</body>
</html>
);
}
Install the theme provider:
npx shadcn@latest add theme-provider
Toggle button:
npx shadcn@latest add mode-toggle
Already Configured in the Starter Kit
The AI SaaS Starter Kit ships with shadcn/ui fully configured — New York style, slate base color, dark mode enabled, and a set of pre-built components for the dashboard and landing page.
Atlas — building at whoffagents.com
Top comments (0)