Tailwind CSS v4 with Next.js
Tailwind v4 uses a CSS-first configuration model — no more tailwind.config.js by default. Import Tailwind directly in your global CSS file and use the new @theme block to customise design tokens.
Tailwind CSS Setup & Best Practices
Tailwind CSS is the most popular styling solution in the Next.js ecosystem — and for good reason. Instead of writing custom CSS for every component, you compose utility classes directly in your JSX. Once it clicks, it's remarkably fast to build with and surprisingly maintainable at scale.
This lesson covers everything from initial setup with Next.js, to the utility-first philosophy, to the patterns that keep large Tailwind codebases clean. By the end you'll understand not just how to use Tailwind, but why it works the way it does and how to avoid the traps that make people give up on it.
1. Setting Up Tailwind CSS v4 with Next.js
If you created your project with create-next-app and selected Tailwind,
it's already configured. If you're adding it to an existing project:
npm install tailwindcss @tailwindcss/postcss postcss
Create a postcss.config.mjs file at the root:
// postcss.config.mjs
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Then import Tailwind in your global CSS file — Tailwind v4 uses a CSS-first configuration approach, meaning you configure everything in CSS rather than a JavaScript config file:
/* app/globals.css */
@import "tailwindcss";
That single import pulls in all of Tailwind's base styles, components, and
utilities. No more @tailwind base, @tailwind components,
@tailwind utilities directives — v4 simplifies this to one line.
Verifying the Setup
Add a Tailwind class to your homepage and run npm run dev:
// app/page.tsx
export default function HomePage() {
return (
<main className="min-h-screen bg-gray-50 flex items-center justify-center">
<h1 className="text-4xl font-bold text-blue-600">
Tailwind is working!
</h1>
</main>
);
}
If you see large blue text centred on a grey background, Tailwind is set up correctly.
2. The Utility-First Philosophy
The core idea of Tailwind is simple: instead of writing this in a CSS file —
/* Traditional CSS */
.card {
background-color: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
You write this directly in your JSX —
// Tailwind — utility classes in JSX
<div className="bg-white rounded-lg p-6 shadow-sm">
Same visual result. No context switching between files. No naming things. No worrying about cascade or specificity. The style lives right next to the markup it affects.
The psychological shift is significant: with traditional CSS you spend mental energy
naming things (.card, .card-header, .card-body)
and keeping CSS and HTML in sync. With Tailwind you just describe what you want and
move on.
The Tailwind Class Anatomy
Most Tailwind classes follow a consistent pattern:
[property]-[value]
text-lg → font-size: 1.125rem
font-bold → font-weight: 700
text-blue-600 → color: #2563eb (blue, shade 600)
bg-gray-100 → background-color: #f3f4f6
p-4 → padding: 1rem (4 × 0.25rem)
px-6 → padding-left: 1.5rem; padding-right: 1.5rem
mt-8 → margin-top: 2rem
w-full → width: 100%
h-screen → height: 100vh
flex → display: flex
items-center → align-items: center
gap-4 → gap: 1rem
rounded-lg → border-radius: 0.5rem
shadow-md → box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1)
border → border-width: 1px
border-gray-200 → border-color: #e5e7eb
Once you've used Tailwind for a week, these mappings become muscle memory. The IntelliSense VS Code extension shows you the CSS value for every class on hover — essential when learning.
3. Responsive Design with Breakpoint Prefixes
Tailwind uses a mobile-first responsive system. Every utility can be prefixed with a breakpoint to apply only at that screen size and above:
sm: → min-width: 640px
md: → min-width: 768px
lg: → min-width: 1024px
xl: → min-width: 1280px
2xl: → min-width: 1536px
// Mobile-first responsive layout
<div className="
grid
grid-cols-1 // 1 column on mobile
md:grid-cols-2 // 2 columns on tablet
lg:grid-cols-3 // 3 columns on desktop
gap-6
">
{products.map((p) => <ProductCard key={p.id} product={p} />)}
</div>
// Responsive typography
<h1 className="text-2xl md:text-4xl lg:text-5xl font-bold">
Welcome to My Store
</h1>
// Show/hide elements at different breakpoints
<nav className="hidden md:flex items-center gap-6">
{/* Desktop nav — hidden on mobile, flex on md+ */}
</nav>
<button className="md:hidden">
{/* Mobile menu button — hidden on md+ */}
☰ Menu
</button>
4. State Variants — Hover, Focus, Active
Prefix any utility with a state variant to apply it conditionally:
// Hover effects
<button className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-colors">
Click me
</button>
// Focus styles (important for accessibility)
<input className="border border-gray-300 rounded-md px-3 py-2
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
// Active state
<button className="bg-blue-600 active:bg-blue-800 active:scale-95 transition-all">
Press me
</button>
// Disabled state
<button
disabled
className="bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
Submit
</button>
// Group hover — hover parent, style child
<div className="group p-4 border rounded-lg hover:border-blue-500">
<h3 className="font-bold group-hover:text-blue-600 transition-colors">
Card Title
</h3>
<p className="text-gray-500 group-hover:text-gray-700">
Card description text here.
</p>
</div>
The group and group-hover: pattern is one of Tailwind's
most powerful features — style any child element based on the hover state of a
parent, without a single line of JavaScript.
5. Dark Mode
Prefix any utility with dark: to apply it in dark mode. We'll
cover full dark mode implementation in Lesson 304, but here's the basic pattern:
// app/globals.css
@import "tailwindcss";
/* In v4, dark mode uses the media query strategy by default */
/* Switch to class strategy for user-controlled dark mode: */
@variant dark (&.dark);
// Component with dark mode styles
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 p-6 rounded-lg">
<h2 className="text-xl font-bold">Card Title</h2>
<p className="text-gray-600 dark:text-gray-400">Card description.</p>
</div>
6. Customising Your Theme with @theme
Tailwind v4 uses the @theme block in your CSS file to customise
design tokens — colours, fonts, spacing, and more. No more tailwind.config.js
for most customisation:
/* app/globals.css */
@import "tailwindcss";
@theme {
/* Custom colours — accessible as bg-brand-500, text-brand-600, etc. */
--color-brand-50: #eff6ff;
--color-brand-100: #dbeafe;
--color-brand-500: #3b82f6;
--color-brand-600: #2563eb;
--color-brand-700: #1d4ed8;
--color-brand-900: #1e3a8a;
/* Custom font families */
--font-sans: "Inter", system-ui, sans-serif;
--font-mono: "Fira Code", monospace;
--font-display: "Cal Sans", sans-serif;
/* Custom spacing */
--spacing-18: 4.5rem;
--spacing-22: 5.5rem;
/* Custom border radius */
--radius-4xl: 2rem;
/* Custom breakpoints */
--breakpoint-xs: 475px;
--breakpoint-3xl: 1920px;
/* Custom shadows */
--shadow-glow: 0 0 20px rgba(59, 130, 246, 0.3);
}
After adding these, you can use them as regular Tailwind classes:
<button className="bg-brand-600 hover:bg-brand-700 text-white rounded-4xl shadow-glow">
Brand Button
</button>
<h1 className="font-display text-brand-900">
Display Heading
</h1>
7. Extracting Components — When to Stop Using Inline Classes
The most common objection to Tailwind is long class strings:
<button className="inline-flex items-center justify-center gap-2 px-4 py-2 text-sm
font-medium text-white bg-blue-600 border border-transparent rounded-lg shadow-sm
hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500
focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed
transition-colors duration-200">
Submit
</button>
If this button appears once in your codebase, inline classes are fine. The real problem is when you copy-paste this 20 times across different pages — now changing the button style means finding and updating 20 places.
The solution is component extraction — not CSS classes. Instead of abstracting into a CSS class, abstract into a React component:
// components/Button.tsx
import clsx from "clsx";
type ButtonProps = {
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "sm" | "md" | "lg";
fullWidth?: boolean;
disabled?: boolean;
children: React.ReactNode;
onClick?: () => void;
type?: "button" | "submit" | "reset";
};
const variants = {
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
secondary: "bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-400",
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
ghost: "bg-transparent text-gray-600 hover:bg-gray-100 focus:ring-gray-400",
};
const sizes = {
sm: "px-3 py-1.5 text-xs rounded-md",
md: "px-4 py-2 text-sm rounded-lg",
lg: "px-6 py-3 text-base rounded-xl",
};
export default function Button({
variant = "primary",
size = "md",
fullWidth = false,
disabled = false,
children,
onClick,
type = "button",
}: ButtonProps) {
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
className={clsx(
// Base styles — every button gets these
"inline-flex items-center justify-center gap-2 font-medium",
"border border-transparent shadow-sm",
"focus:outline-none focus:ring-2 focus:ring-offset-2",
"transition-colors duration-200",
"disabled:opacity-50 disabled:cursor-not-allowed",
// Variant styles
variants[variant],
// Size styles
sizes[size],
// Optional full width
fullWidth && "w-full",
)}
>
{children}
</button>
);
}
// Usage — clean, typed, consistent
<Button variant="primary" size="md">Save Changes</Button>
<Button variant="secondary" size="sm">Cancel</Button>
<Button variant="danger" fullWidth>Delete Account</Button>
This is the right abstraction level in Tailwind — React components, not CSS classes. The classes are colocated with the component, the API is typed, and changing the button style means editing one file.
8. The cn() Utility — Merging Classes Safely
A problem arises when a parent passes className to a component that
already has Tailwind classes — conflicting utilities don't override each other
predictably because CSS class order in the stylesheet matters, not DOM order:
// ❌ This doesn't reliably work — p-4 might not override p-6
<Button className="p-4">
{/* Button internally has p-6 — which wins? */}
</Button>
Install tailwind-merge to solve this — it intelligently merges
Tailwind classes, automatically removing conflicts:
npm install tailwind-merge clsx
// lib/utils.ts — the cn() utility used everywhere in Next.js projects
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Now use cn() everywhere instead of clsx() alone
import { cn } from "@/lib/utils";
export default function Button({
className,
variant = "primary",
children,
...props
}: ButtonProps & { className?: string }) {
return (
<button
className={cn(
"px-4 py-2 rounded-lg font-medium transition-colors",
variants[variant],
className, // ← Parent classes correctly override internal ones
)}
{...props}
>
{children}
</button>
);
}
// Now this works correctly — p-2 overrides internal py-2
<Button className="p-2">Compact Button</Button>
The cn() utility — combining clsx for conditional
classes and twMerge for conflict resolution — is used in virtually
every serious Next.js + Tailwind project. Create it once in lib/utils.ts
and import it everywhere.
9. Tailwind with shadcn/ui
If you want a full component library built on Tailwind, shadcn/ui is the dominant choice in the Next.js ecosystem. Unlike traditional component libraries, shadcn/ui copies components directly into your project — you own the code and can modify every detail:
npx shadcn@latest init
Then add individual components as needed:
npx shadcn@latest add button
npx shadcn@latest add input
npx shadcn@latest add dialog
npx shadcn@latest add dropdown-menu
Each component is added to components/ui/ as a regular TypeScript
file you can read and customise. They're built with Radix UI primitives for
accessibility and styled with Tailwind + the cn() utility.
// Using shadcn/ui Button
import { Button } from "@/components/ui/button";
<Button variant="outline" size="sm">
Open Dialog
</Button>
shadcn/ui is an excellent starting point — use it for the standard UI primitives (buttons, inputs, dialogs, dropdowns) and build custom components with raw Tailwind for your app-specific UI.
10. Performance — How Tailwind Stays Fast
A common misconception: "Won't a massive CSS framework make my page slow?" The opposite is true. Tailwind v4 uses a Rust-based engine that scans your source files and only generates CSS for the classes you actually use. A production Tailwind stylesheet is typically 5–20KB — smaller than most hand-written CSS files.
There's nothing to configure for this — it works automatically. Tailwind scans
all .tsx, .ts, .jsx, and .js
files in your project and builds a minimal stylesheet from the classes it finds.
One implication: never dynamically construct Tailwind class names — the scanner won't find them and they won't be included in the output:
// ❌ Dynamic class construction — scanner can't detect these
const color = "blue";
<div className={`text-${color}-600`}> // text-blue-600 NOT included in output
// ✅ Use complete class names — scanner detects these
const classes = { blue: "text-blue-600", red: "text-red-600" };
<div className={classes[color]}> // text-blue-600 IS included
11. Common Gotchas
- Constructing class names dynamically. As shown above, always use complete Tailwind class names as strings — never build them with string interpolation. Use a lookup object instead.
-
Conflicting utilities without twMerge. If a parent passes
className="p-2"to a component that hasp-6internally, both classes are applied and CSS specificity determines which wins — often unpredictably. Always use thecn()utility for components that accept aclassNameprop. -
Overusing
@apply. Tailwind has an@applydirective that lets you write Tailwind classes in a CSS file. It's tempting but usually the wrong solution — you're recreating the CSS file abstraction that Tailwind was designed to replace. Extract a React component instead. -
Not installing the Tailwind IntelliSense VS Code extension.
Without it, writing Tailwind is painful — you're guessing class names.
With it, you get autocomplete, hover documentation, and linting for every class.
Install
bradlc.vscode-tailwindcssimmediately. -
Forgetting the mobile-first order.
md:text-lgapplies on medium screens and above — not just medium screens. Start with the mobile layout (no prefix) and layer on larger screen styles with prefixes. Don't write desktop styles first and try to override them on mobile.
Key Takeaways
- Tailwind v4 setup is a single
@import "tailwindcss"in your global CSS — no config file needed for most projects. - Utility classes go directly in JSX — no context switching to a CSS file, no naming things.
- Responsive design uses mobile-first breakpoint prefixes:
sm:,md:,lg:,xl:. - State variants like
hover:,focus:,disabled:, andgroup-hover:replace pseudo-class CSS rules. - Customise your design system with
@themein globals.css — custom colours, fonts, spacing, and breakpoints. - Extract React components, not CSS classes — the Button component pattern keeps classes maintainable.
- Create a
cn()utility combiningclsxandtailwind-mergefor safe class composition. - Never construct class names dynamically — always use complete class strings the scanner can detect.
- shadcn/ui gives you a production-ready Tailwind component library that you own and can modify.
Next up: Lesson 303 — Global Styles & Fonts with next/font. You'll learn how to load Google Fonts and local fonts with zero layout shift, self-host them for privacy and performance, and apply them across your app with CSS variables.