Shadcn/UI + Next.js: Component System That Actually Stays in Your Codebase
I spent two years fighting MUI's styling system. Every upgrade broke my custom theme. Every new component needed hours of override CSS. When I found Shadcn/UI, the "copy-paste components" pitch sounded gimmicky. But after migrating three production apps to it, I get it now — the components are yours. You own the code. No more wrestling with !important or buried class names.
Why Copy-Paste Components Win
Here's the thing about traditional UI libraries: you're renting components. MUI, Ant Design, Chakra — they live in node_modules. When you need to change how a Select dropdown renders its options, you dig through their source, find the right override prop, and hope it doesn't break on the next version bump.
Shadcn/UI flips this. You run npx shadcn@latest add button and it drops a button.tsx file into your components/ui/ folder. You open it, read it, change it. It's just React code using Radix UI primitives and Tailwind classes. No wrapper hell, no theme provider nesting.
I migrated a dashboard from MUI v5 to Shadcn in about 3 days. The MUI version had 47 files with makeStyles or sx props. The Shadcn version has 12 files total, and I can grep any styling change in seconds.
Setting Up with Next.js 15
I tested this with Next.js 15.2, React 19, and Tailwind CSS v4. The init command handles most of the setup, but I hit a few configuration issues.
# Create Next.js project
npx create-next-app@latest my-app --typescript --tailwind --eslint --app
# Initialize shadcn
npx shadcn@latest init
# It asks you a few questions:
# - Style: New York (I prefer this over Default)
# - Base color: Slate
# - CSS variables: yes
# - Tailwind CSS v4: yes
# Add components you need
npx shadcn@latest add button input form select dialog table
The init command creates a components.json file at the root. This tells the CLI where to put components. My config looks like this:
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui"
}
}
Problem
I was building a form with Shadcn's Form component (which wraps react-hook-form) and Zod validation. The form submit worked fine, but error messages wouldn't show up next to the input fields. The formState.errors object was populated, but the UI didn't render anything.
What I Tried
First I tried using raw react-hook-form without the Shadcn Form wrapper — errors showed up but I lost the nice styling. Then I tried manually passing error props to the FormMessage component, but that created duplicate error rendering.
Actual Fix
The issue was that I wasn't using the FormField, FormItem, FormLabel, and FormMessage components together in the right structure. Each field needs to be wrapped in FormField with a render function that uses the full structure:
// Working pattern for Shadcn Form + react-hook-form + Zod
"use client"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import {
Form, FormControl, FormField,
FormItem, FormLabel, FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
const formSchema = z.object({
email: z.string().email("Enter a real email"),
password: z.string().min(8, "At least 8 characters"),
})
export function LoginForm() {
const form = useForm>({
resolver: zodResolver(formSchema),
defaultValues: { email: "", password: "" },
})
function onSubmit(values: z.infer) {
console.log(values) // send to API
}
return (
)
}
Building a Real Form with Validation
Most form tutorials stop at a login form. Let me show you what a real production form looks like — with conditional fields, async validation, and server-side error handling.
// Registration form with conditional fields and async validation
const registerSchema = z.object({
username: z.string()
.min(3)
.refine(async (name) => {
// Check if username is taken
const res = await fetch(`/api/check-username?name=${name}`)
const data = await res.json()
return data.available
}, "Username is taken"),
email: z.string().email(),
accountType: z.enum(["personal", "business"]),
// Conditional field — only required for business accounts
companyName: z.string().optional(),
}).refine(
(data) => data.accountType !== "business" || !!data.companyName,
{ message: "Company name required for business accounts", path: ["companyName"] }
)
export function RegisterForm() {
const form = useForm>({
resolver: zodResolver(registerSchema),
defaultValues: {
username: "", email: "", accountType: "personal", companyName: "",
},
})
const accountType = form.watch("accountType")
async function onSubmit(values: z.infer) {
try {
const res = await fetch("/api/register", {
method: "POST",
body: JSON.stringify(values),
})
if (!res.ok) {
const error = await res.json()
// Show server-side field errors
if (error.field === "username") {
form.setError("username", { message: error.message })
}
return
}
router.push("/dashboard")
} catch (err) {
// Network error
form.setError("root", { message: "Something went wrong" })
}
}
return (
)
}
Problem
I set up dark mode with next-themes and custom CSS variables in globals.css. Light mode looked fine, but switching to dark mode caused some components to have invisible text (white text on white background). The issue was that my custom color variables weren't properly scoped for both themes.
What I Tried
I tried adding !important to the dark mode variables. That fixed some components but broke others that relied on the default values. Then I tried using Tailwind's dark: prefix everywhere, which worked but meant touching 30+ component files.
Actual Fix
The fix was defining CSS variables correctly for both :root and .dark in globals.css. Shadcn uses HSL values, not hex. Here's the pattern:
/* globals.css */
@layer base {
:root {
--background: 0 0% 100%;
--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%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
Custom Theming That Doesn't Break
The trick with Shadcn theming is: don't fight the system. All components reference the CSS variables defined above. If you want a custom brand color, change the variable values. Don't override component classes individually.
Here's how I set up a custom brand color scheme for a client project:
/* Custom brand theme — just change the HSL values */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--primary: 262 83% 58%; /* Purple brand color */
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--accent: 262 83% 95%; /* Light purple accent */
--destructive: 0 84.2% 60.2%;
--border: 240 5.9% 90%;
--ring: 262 83% 58%; /* Matches primary */
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--primary: 262 83% 58%;
--primary-foreground: 0 0% 98%;
--secondary: 240 3.7% 15.9%;
--accent: 262 83% 20%;
--destructive: 0 62.8% 30.6%;
--border: 240 3.7% 15.9%;
--ring: 262 83% 58%;
}
}
After updating these variables, every Shadcn component picks up the new colors. Buttons, inputs, dialogs — all consistent. No touching individual component files.
Server Components Gotchas
This is where I burned the most time. Shadcn components use Radix UI primitives, which need client-side JavaScript for things like focus management, keyboard navigation, and portal rendering. That means most Shadcn components can't be pure Server Components.
Here's the pattern that works:
// app/dashboard/page.tsx — this IS a Server Component
import { UserTable } from "./user-table" // Client Component
import { db } from "@/lib/db"
export default async function DashboardPage() {
// Fetch data on the server
const users = await db.user.findMany()
return (
Users
{/* Pass server data to client component */}
)
}
// app/dashboard/user-table.tsx — this needs "use client"
"use client"
import { DataTable } from "@/components/data-table"
import { columns } from "./columns"
export function UserTable({ users }: { users: User[] }) {
return
}
The rule of thumb: if a component has any interactivity (click handlers, state, effects), it needs "use client". Keep your page components as Server Components that fetch data, then pass that data down to Client Components that handle the UI.
Components That Work as Server Components
- Static text content — Card, CardHeader, CardTitle, CardDescription (without interactivity)
- Badge — if just displaying text
- Separator — purely visual
- Typography — h1, h2, p tags with Tailwind classes
Components That Need "use client"
- Dialog/Sheet — needs open/close state + portal
- Select/DropdownMenu — needs focus management
- Tabs — needs active tab state
- Form — needs react-hook-form state
- Table with sorting/pagination — needs state
Problem
Got Hydration failed because the server rendered HTML didn't match the client errors when rendering Shadcn Dialog components that were conditionally shown based on server data.
What I Tried
Tried wrapping everything in suppressHydrationWarning — bad idea, masks real issues. Tried using dynamic imports with ssr: false — caused layout shift.
Actual Fix
The Dialog content was rendering differently on server vs client because of portal rendering. The fix is to control the open state with client-side state only, and don't try to render Dialog content during SSR:
"use client"
import { useState } from "react"
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"
export function EditDialog({ userId }: { userId: string }) {
const [open, setOpen] = useState(false)
return (
)
}
What I Learned
- Own your components. Copy-paste sounds messy, but it's better than fighting
node_modulesoverrides for hours. - Zod + react-hook-form + Shadcn Form is the validation stack. Don't try to skip the Shadcn Form wrapper — it handles error display.
- Theme by changing CSS variables, not by overriding component classes. Every component reads from the same variable set.
- Server/Client boundary matters. Keep pages as Server Components for data fetching. Only mark leaf components with
"use client". - The Shadcn CLI diffs your changes. When you update a component, it shows you what changed. You decide what to keep. This is safer than
npm updatebreaking your entire UI.
Wrapping Up
After a year with Shadcn/UI across three production apps, I wouldn't go back to MUI or Ant Design. The copy-paste model means I can customize anything without fighting the library. The components are well-built (thanks to Radix UI primitives underneath), and the Tailwind integration means styling is predictable. If you're starting a new Next.js project in 2026, this is the component system to use.