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 (
    
( Email {/* This shows the Zod error */} )} /> ( Password )} /> ) }

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 (
    
{/* username, email fields... */} ( Account Type )} /> {accountType === "business" && ( ( Company Name )} /> )} ) }

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

Components That Need "use client"

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 (
    
      
        
      
      
        {/* Content only renders when dialog is open */}
        {/* Fetch data inside, not from parent server component */}
        {open && }
      
    
  )
}

What I Learned

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.

Related Articles