DEV Community

Cover image for Setting Up Next.js 15 with Tailwind v4 and shadcn/ui — What Actually Changed
Abhishek Singh
Abhishek Singh

Posted on

Setting Up Next.js 15 with Tailwind v4 and shadcn/ui — What Actually Changed

I recently set up a new project with Next.js 15 (App Router), Tailwind CSS v4, and shadcn/ui. If you're coming from Tailwind v3, some things have changed and the official docs don't always make it obvious what breaks.

Here's what I ran into and how I got everything working.

Initial setup

npx create-next-app@latest my-app --typescript --tailwind --eslint --app --src-dir
cd my-app
Enter fullscreen mode Exit fullscreen mode

As of Next.js 15, create-next-app installs Tailwind v4 by default. This is where the differences start.

What changed in Tailwind v4

No more tailwind.config.js

This was the biggest surprise. Tailwind v4 uses CSS-based configuration instead of a JavaScript config file. Your customization now lives in your CSS file:

/* src/app/globals.css */
@import "tailwindcss";

@theme {
  --color-brand: #2563eb;
  --color-brand-dark: #1d4ed8;
  --color-surface: #0f0f0f;
  --color-surface-light: #1a1a1a;
  --color-border: #2a2a2a;

  --font-sans: "Inter", system-ui, sans-serif;
  --font-mono: "JetBrains Mono", monospace;

  --radius-lg: 12px;
  --radius-md: 8px;
  --radius-sm: 4px;
}
Enter fullscreen mode Exit fullscreen mode

No more theme.extend.colors in a JS file. Everything is CSS custom properties now. Honestly, once I got used to it, this feels cleaner.

@import instead of @tailwind directives

Old way (v3):

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

New way (v4):

@import "tailwindcss";
Enter fullscreen mode Exit fullscreen mode

That's it. One line.

Setting up shadcn/ui

This is where I ran into issues. Running the init command:

npx shadcn@latest init
Enter fullscreen mode Exit fullscreen mode

It asks you a bunch of questions. Pick these:

  • Style: Default
  • Base color: Neutral (or whatever you prefer)
  • CSS variables: Yes

After init, it creates a components.json and updates your globals.css with CSS variables for the theme.

The CSS variables shadcn adds

shadcn/ui injects its color system using CSS variables. In v4, it looks like this in your globals.css:

@import "tailwindcss";

@custom-variant dark (&:is(.dark *));

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);
  --color-accent: var(--accent);
  --color-accent-foreground: var(--accent-foreground);
  --color-destructive: var(--destructive);
  --color-border: var(--border);
  --color-ring: var(--ring);
  /* ...more variables */
}
Enter fullscreen mode Exit fullscreen mode

Then the actual color values are defined per theme:

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  /* ...etc */
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --primary: oklch(0.922 0 0);
  --primary-foreground: oklch(0.205 0 0);
  /* ...etc */
}
Enter fullscreen mode Exit fullscreen mode

Notice they use oklch color space now instead of hsl. This gives more perceptually uniform colors.

Adding components

npx shadcn@latest add button card input dialog
Enter fullscreen mode Exit fullscreen mode

Components go into src/components/ui/. Each one is a standalone file you can edit directly — that's the whole point of shadcn. It's not a package in node_modules, it's code you own.

Dark mode setup

With Next.js App Router and Tailwind v4, here's the minimal dark mode setup:

npm install next-themes
Enter fullscreen mode Exit fullscreen mode
// src/components/theme-provider.tsx
"use client";

import { ThemeProvider as NextThemesProvider } from "next-themes";

export function ThemeProvider({
  children,
  ...props
}: React.ComponentProps<typeof NextThemesProvider>) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
Enter fullscreen mode Exit fullscreen mode
// 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="dark"
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

The suppressHydrationWarning on <html> is needed because next-themes adds the theme class on the client side, which creates a hydration mismatch. This is expected and the warning is safe to suppress.

Building a dashboard card

Here's a practical example combining everything — a stats card for a dashboard:

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TrendingUp, TrendingDown } from "lucide-react";

interface StatCardProps {
  title: string;
  value: string;
  change: number;
  period: string;
}

export function StatCard({ title, value, change, period }: StatCardProps) {
  const isPositive = change >= 0;

  return (
    <Card>
      <CardHeader className="flex flex-row items-center justify-between pb-2">
        <CardTitle className="text-sm font-medium text-muted-foreground">
          {title}
        </CardTitle>
        {isPositive ? (
          <TrendingUp className="h-4 w-4 text-green-500" />
        ) : (
          <TrendingDown className="h-4 w-4 text-red-500" />
        )}
      </CardHeader>
      <CardContent>
        <div className="text-2xl font-bold">{value}</div>
        <p className="text-xs text-muted-foreground mt-1">
          <span className={isPositive ? "text-green-500" : "text-red-500"}>
            {isPositive ? "+" : ""}{change}%
          </span>
          {" "}from {period}
        </p>
      </CardContent>
    </Card>
  );
}
Enter fullscreen mode Exit fullscreen mode

Usage:

<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
  <StatCard title="Total Orders" value="1,234" change={12.5} period="last month" />
  <StatCard title="Revenue" value="₹4,56,789" change={8.2} period="last month" />
  <StatCard title="Active Users" value="892" change={-3.1} period="last week" />
  <StatCard title="Conversion" value="3.2%" change={0.8} period="last month" />
</div>
Enter fullscreen mode Exit fullscreen mode

Things that caught me off guard

1. cn() utility is essential

shadcn generates a lib/utils.ts with a cn() function that merges Tailwind classes properly. Always use it when combining conditional classes:

import { cn } from "@/lib/utils";

<div className={cn(
  "rounded-lg border p-4",
  isActive && "border-primary bg-primary/10",
  isDisabled && "opacity-50 cursor-not-allowed"
)} />
Enter fullscreen mode Exit fullscreen mode

Without cn(), conflicting Tailwind classes don't resolve correctly. I had a bug where bg-red-500 wasn't overriding bg-blue-500 because of class order. cn() handles this using tailwind-merge under the hood.

2. withOpacity is deprecated

If you're upgrading from an older project, color.withOpacity(0.5) is deprecated in Flutter — wait, wrong framework. In Tailwind v4, the opacity modifier syntax changed slightly. Use bg-primary/10 for 10% opacity, which still works the same way.

3. Server Components and "use client"

shadcn/ui components that use useState, useEffect, or event handlers need the "use client" directive. The shadcn CLI adds this automatically, but if you create wrapper components, don't forget it. I spent 20 minutes debugging a dialog that wouldn't open because my wrapper was missing "use client".

4. Import paths

shadcn uses @/components/ui/button style imports. Make sure your tsconfig.json has the path alias:

{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

create-next-app sets this up automatically, but if you're adding shadcn to an existing project, check this first.

Is Tailwind v4 worth upgrading?

For new projects — yes, absolutely. The CSS-based config is cleaner, the build is faster, and oklch colors look better.

For existing v3 projects — wait until your dependencies (shadcn, other UI libraries) fully support v4. The migration isn't painful but there's no rush if everything works.


I'm Abhishek, a full-stack developer building SaaS products and mobile apps with Flutter, Next.js, and Node.js. I write about what I learn while building real projects.

Top comments (0)