The actual integration of Tailwind v4 + shadcn 2.3.0 in Next.js 16 App Router. OKLCH, FOUC-free dark mode, Geist with next/font, and the silent hsl(var()) bug that breaks your styles without warning.
For a couple of years we've been arguing over "which component library to use". In 2026 the question is settled: Tailwind v4 + shadcn/ui on top of Next.js 16 App Router. But the actual setup, when you do it, has five or six gotchas that no single post puts together in one place.
This guide is the piece that was missing. It comes from the current setup of the CREA.MBA boilerplate — not made up, not rewritten to look clean.
There's a good post on why Next.js 16 with App Router is the default. There's another on Tailwind v4 + shadcn/ui in 2026. Both are fine. But neither covers the actual integration.
What happens when you put the three pieces together is where things go sideways:
→ tailwind.config.js no longer exists, and nobody tells you where custom variants go.
→ shadcn 2.3.0 migrated from HSL to OKLCH and a lot of old code silently breaks.
→ Dark mode with Server Components has a FOUC that's nearly impossible to remove unless you know three specific props.
→ shadcn animations (Dialog, Sheet, Drawer) need a different package than the Tailwind v3 one.
→ next/font and the Tailwind v4 token system don't talk to each other by default.
Let's go through them one by one with exact code.
Start with dependencies. In package.json:
{
"dependencies": {
"next": "^16.2.6",
"react": "19.2.3",
"next-themes": "^0.4.6",
"tailwind-merge": "^3.4.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1"
},
"devDependencies": {
"tailwindcss": "^4.1.18",
"@tailwindcss/postcss": "^4.1.18",
"tw-animate-css": "^1.4.0"
}
}Three details people miss:
→ tailwindcss-animate no longer works in v4. It's replaced by tw-animate-css.
→ @tailwindcss/postcss is the official Next.js plugin. It replaces tailwindcss as the PostCSS plugin.
→ There's no tailwindcss-cli and no tailwind.config.js. Everything is configured in CSS.
PostCSS config (postcss.config.mjs):
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;That's it. v3 had five or six lines with autoprefixer, tailwindcss/nesting, postcss-import. v4 bundles all of it.
shadcn config (components.json):
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/shared/components",
"utils": "@/shared/utils",
"ui": "@/shared/components/ui",
"lib": "@/lib",
"hooks": "@/shared/hooks"
}
}Three keys:
→ "config": "" — empty. There's no tailwind.config.js in v4.
→ "rsc": true — you're using Server Components.
→ "cssVariables": true — the palette lives in CSS variables, not classes.
This is where everything happens. Import Tailwind, define variants, map variables to the theme, declare the palette in :root and .dark. Full structure:
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border);
--color-ring: var(--ring);
/* ... the rest of the shadcn colors */
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.75rem;
--background: oklch(0.985 0.007 65);
--foreground: oklch(0.155 0.015 55);
--primary: oklch(0.21 0.02 265);
--primary-foreground: oklch(0.985 0.003 65);
--muted: oklch(0.955 0.01 65);
--muted-foreground: oklch(0.52 0.015 55);
--border: oklch(0.92 0.01 65);
--ring: oklch(0.708 0.015 265);
/* ... */
}
.dark {
--background: oklch(0.23 0.008 260);
--foreground: oklch(0.985 0.005 270);
/* ... */
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground font-sans;
}
}Four important things:
→ @custom-variant dark (&:is(.dark *)); replaces what used to be darkMode: 'class' in the v3 config. Without a config, you define the variant in CSS.
→ @theme inline { ... } is the block that connects your custom variables to the Tailwind system. Without it, bg-primary won't work even if you have --primary defined.
→ Colors go in OKLCH, not HSL. Shadcn 2.3.0+ migrated for a reason: OKLCH covers the P3 color space (modern displays), and manipulating it (darken, lighten, mix) gives perceptually correct results. HSL doesn't.
→ font-sans maps to --font-geist-sans via @theme inline. That makes the font-sans class automatically use your Geist font.
The trick to make next/font and Tailwind v4 talk is this three-step chain.
In root layout.tsx:
import { Geist, Geist_Mono } from 'next/font/google';
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
});
export default function RootLayout({ children }: { children: ReactNode }) {
return (
{children}
);
}And in globals.css the other end of the chain is already there:
@theme inline {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}Full chain: font-sans class (Tailwind) → --font-sans (Tailwind v4 theme) → --font-geist-sans (next/font). The whole app uses Geist with no extra imports, no in, no layout shift.
Three specific props, one HTML directive, and a pattern on the toggle. Miss one and you get the white flash.
1. ThemeProvider wraps the app:
// src/shared/components/theme-provider.tsx
'use client';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import type { ComponentProps } from 'react';
type ThemeProviderProps = ComponentProps;
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return {children};
}'use client' is mandatory. next-themes uses context and hooks.
2. Apply it in the root layout:
{children}
The three critical props:
→ attribute="class" — the .dark (or .light) class gets applied to ``, which activates your &:is(.dark *) variant.
→ defaultTheme="system" + enableSystem — respects the user's OS preference.
→ disableTransitionOnChange — prevents the whole UI from animating when you switch modes (it looks weird because every color transitions at the same time).
And suppressHydrationWarning on silences the React warning that would appear because next-themes injects the class on the server before hydration (client and server briefly see different classes on).
3. The toggle: useSyncExternalStore pattern to detect mount:
// src/shared/components/theme-toggle.tsx
'use client';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { useSyncExternalStore } from 'react';
import { Button } from '@/shared/components/ui/button';
function useMounted() {
return useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
}
export function ThemeToggle() {
const { setTheme, theme } = useTheme();
const mounted = useMounted();
if (!mounted) {
return (
);
}
return (
setTheme(theme === 'dark' ? 'light' : 'dark')}
>
);
}useSyncExternalStore returns false on the server and true on the client. It's React's official API for subscribing to external stores, and it's more efficient than the classic useState + useEffect pattern because it doesn't trigger an extra re-render.
Without this pattern the toggle would flicker on hydration, because theme is undefined during SSR.
If you're coming from Tailwind v3 with shadcn, you had tailwindcss-animate as a plugin. In v4 it doesn't work — the plugin assumes a JS config that no longer exists. Switch to tw-animate-css and import it as CSS:
@import 'tw-animate-css';The class names are the same (animate-in, slide-in-from-top, etc.), but the package providing them is different.
hsl(var(--X)) after migrating to OKLCHIt's the most silent bug in the migration. You used to have:
color: hsl(var(--muted-foreground));When --muted-foreground was "0 0% 55%" (loose HSL components), this worked. But after migrating to OKLCH:
--muted-foreground: oklch(0.52 0.015 55);hsl(var(--muted-foreground)) evaluates to hsl(oklch(0.52 0.015 55)), which is invalid CSS. The browser silently ignores the rule and your styles break with no visible console error.
Fix: after migrating to OKLCH, use var(--X) directly:
color: var(--muted-foreground);If you need alpha, use color-mix:
border-color: color-mix(in oklch, var(--foreground) 40%, transparent);'use client' where neededYou don't need to add 'use client' to Dialog, Sheet, Dropdown, etc. They include it themselves. Just import them:
import { Dialog } from '@/shared/components/ui/dialog';Your page can be a Server Component, and only the Dialog (and its interactive children) runs on the client. That's the advantage of App Router with shadcn.
→ postcss.config.mjs: just @tailwindcss/postcss.
→ globals.css: @import 'tailwindcss', @import 'tw-animate-css', @custom-variant dark, @theme inline with your tokens, OKLCH palette in :root and .dark.
→ components.json: style: new-york, rsc: true, tailwind.config: "", cssVariables: true.
→ layout.tsx: ``, Geist fonts as variables, ThemeProvider wrapping everything with attribute="class", defaultTheme="system", enableSystem, disableTransitionOnChange.
→ theme-toggle.tsx: useSyncExternalStore pattern to detect mount without hydration mismatch.
This is the combination I run in production on CREA.MBA and the boilerplate. No FOUC, no hydration warnings, no broken styles when switching themes.
If you have an old app with hardcoded HSL, swap hsl(var(--X)) for var(--X) the same day you update the palette. Don't leave it for "later" — you'll spend a week wondering why your blog has no colors.
Subscribe for more tutorials and tips on building products with AI