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
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;
}
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;
New way (v4):
@import "tailwindcss";
That's it. One line.
Setting up shadcn/ui
This is where I ran into issues. Running the init command:
npx shadcn@latest init
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 */
}
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 */
}
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
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
// 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>;
}
// 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>
);
}
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>
);
}
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>
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"
)} />
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/*"]
}
}
}
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)