Let's be real for a second. We need to talk about the elephant in the room when it comes to Tailwind CSS.
You've built a sleek Next.js app.
You love the utility-first approachβno more fighting with CSS files. But then you open a shared component (a Button, a Modal, an Alert), and your stomach drops.
There it is. The Class Name String From Hell.
The Pain: What Your Code Probably Looks Like Right Now
Here's what most developers do. They create a Button component that looks like this:
File: components/ui/BadButton.tsx (What NOT to do)
// β components/ui/BadButton.tsx - The WRONG way (what you're doing now)
interface BadButtonProps {
variant?: 'primary' | 'secondary' | 'danger'
size?: 'small' | 'medium' | 'large'
disabled?: boolean
children: React.ReactNode
onClick?: () => void
}
export function BadButton({
variant,
size,
disabled,
children,
onClick,
}: BadButtonProps) {
const btnClasses = `transition duration-200 ease-in-out font-semibold rounded-md ${
disabled ? 'opacity-50 cursor-not-allowed' : 'hover:shadow-lg'
} ${
variant === 'primary'
? 'bg-indigo-600 text-white'
: variant === 'secondary'
? 'bg-gray-200 text-gray-800'
: variant === 'danger'
? 'bg-red-600 text-white'
: 'bg-gray-100 text-gray-600'
} ${
size === 'large'
? 'py-3 px-6 text-lg'
: size === 'medium'
? 'py-2 px-4 text-base'
: 'py-1 px-3 text-sm'
}`
return (
<button className={btnClasses} disabled={disabled} onClick={onClick}>
{children}
</button>
)
}
Thirty lines of nested ternary operators. When your designer asks for a new button variant, you don't edit stylesβyou decode a puzzle.
Then You Try to Use It (And It Gets Worse)
Okay, so you've created that messy BadButton component. Now let's see what happens when you try to actually use it across your app:
File: app/dashboard/page.tsx (Using the BadButton)
'use client';
import { useState } from 'react';
import { BadButton } from '@/components/ui/BadButton';
export default function DashboardPage() {
const [isSubmitting, setIsSubmitting] = useState(false);
return (
<div className="p-8">
<h1>Dashboard</h1>
{/* Using BadButton - but look at the confusion */}
<BadButton
variant="primary"
size="large"
disabled={isSubmitting}
onClick={() => setIsSubmitting(true)}
>
Submit Form
</BadButton>
{/* Another BadButton - inconsistent prop names */}
<BadButton
variant="danger"
size="medium" // Wait, was it 'medium' or 'md'?
onClick={() => console.log('delete')}
>
Delete Account
</BadButton>
</div>
);
}
File: app/login/page.tsx (When you give up entirely)
'use client';
import { useState } from 'react';
// Notice: NO import of BadButton - you gave up on it!
export default function LoginPage() {
const [isSubmitting, setIsSubmitting] = useState(false);
return (
<form className="p-8">
<input type="email" placeholder="Email" className="block mb-4" />
<input type="password" placeholder="Password" className="block mb-4" />
{/* You gave up on BadButton and just inlined everything */}
<button
className={`transition duration-200 ease-in-out font-semibold rounded-md ${
isSubmitting ? 'opacity-50 cursor-not-allowed' : 'hover:shadow-lg'
} ${
'bg-indigo-600 text-white' // hardcoded primary
} ${
'py-2 px-4 text-base' // hardcoded medium size
}`}
disabled={isSubmitting}
onClick={(e) => {
e.preventDefault();
setIsSubmitting(true);
}}
>
{isSubmitting ? 'Logging in...' : 'Login'}
</button>
</form>
);
}
Now you have TWO different approaches in your codebase:
-
Dashboard - Uses
BadButtoncomponent (messy but at least reusable) - Login - Gave up entirely and inlined everything (copy-paste hell)
Notice the problems?
-
No type safety - You can pass
variant="success"and it won't error, it'll just fall back to a default -
Inconsistent naming - Is it
"large"or"lg"? Is it"medium"or"md"? - No autocomplete - Your editor can't help you because there are no proper TypeScript types
-
The component itself is still a mess - That ternary soup in
BadButton.tsxmakes it hard to add new variants - Developers bypass the component entirely - They just copy-paste inline styles because the component is too confusing
But the biggest issue? Every time you need to add a new button style, you have to edit that nightmare ternary chain. Want to add a "warning" variant? Good luck finding where to insert it in that nested conditional mess.
This is how codebases become unmaintainable. Two files, two different approaches, zero consistency.
The Mindset Shift: Stop Building Strings, Start Declaring Outcomes
Here's the conceptual leap that changed everything for me when building production SaaS apps.
The Old Way (Imperative): Your brain traces how the class string gets assembled, line by line, condition by condition. It's mentally exhausting.
The New Way (Declarative): You define all possible style outcomes upfront, outside your component. The component's only job is to look up the right styles and render them.
This is what I call the Variant Map Pattern, and it's powered by a tiny library called clsx.
Setting Up Your Project
Before we dive in, let's get everything installed. You have two options:
Option 1: Start Fresh (Recommended for Learning)
# Create a new Next.js project
npx create-next-app@latest nextjs-tailwind-variant-starter
# Choose "Yes, use recommended defaults" when prompted
# This installs Next.js, TypeScript, Tailwind CSS, and ESLint automatically
cd nextjs-tailwind-variant-starter
# Install clsx for smart class merging
npm install clsx
Option 2: Clone the Starter Template (Jump Right In)
# Clone the pre-configured starter with examples
git clone https://github.com/Mayoyo25/nextjs-tailwind-variant-patterns.git
cd nextjs-tailwind-variant-patterns
# Install all dependencies
npm install
The Solution: Build It Once, Use It Everywhere
Now let me show you the RIGHT way to build a Button component. Everything will be organized in proper files.
Step 1: Create Your Button Component
File: components/ui/Button.tsx (The CORRECT way)
// β
DO THIS - Clean, maintainable, professional
import React from 'react';
import clsx from 'clsx';
// π TYPES: Define what variants and sizes are allowed
type ButtonVariant = 'primary' | 'secondary' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';
// π¨ STYLE MAPS: All possible styles defined ONCE
// This is the secret sauce - declare your styles upfront
const variantStyles: Record<ButtonVariant, string> = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-400',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
};
const sizeStyles: Record<ButtonSize, string> = {
sm: 'py-1 px-3 text-sm',
md: 'py-2 px-4 text-base',
lg: 'py-3 px-6 text-lg',
};
// β‘ COMPONENT: Props interface
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
isLoading?: boolean;
}
// β‘ COMPONENT: The actual Button
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
isLoading,
className,
children,
...props
}) => {
// This is where clsx does its magic
const classes = clsx(
// 1. Base styles (always applied)
'transition-all duration-200 ease-in-out font-semibold rounded-md focus:outline-none focus:ring-2',
// 2. Variant and size (simple lookups - no ternaries!)
variantStyles[variant],
sizeStyles[size],
// 3. Conditional states (clean object syntax)
{
'opacity-50 cursor-not-allowed': props.disabled || isLoading,
'animate-pulse': isLoading,
},
// 4. User overrides (always last so they can override defaults)
className
);
return (
<button
className={classes}
disabled={props.disabled || isLoading}
{...props}
>
{isLoading ? 'Loading...' : children}
</button>
);
};
Look at that! No ternary soup. No string concatenation hell. Just clean, declarative code.
Step 2: Use Your Button Everywhere (Consistently!)
Now in any page or component, just import and use it. Every button will look consistent.
File: app/dashboard/page.tsx (Using the clean Button)
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/Button'
export default function DashboardPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = () => {
setIsSubmitting(true)
// Your submit logic here
setTimeout(() => setIsSubmitting(false), 2000)
}
const handleDelete = () => {
if (confirm('Are you sure?')) {
// Your delete logic here
}
}
return (
<div className='p-8 space-y-4'>
{/* using our New Button */}
<div className='flex justify-between items-center mb-6'>
<Button
variant='secondary'
size='sm'
onClick={() => window.history.back()}
>
Back
</Button>
<h1 className='text-2xl font-bold'>Dashboard</h1>
<Button variant='primary' size='sm'>
<a href='/settings'>Go to Settings</a>
</Button>
</div>
{/* 2. Main Content Buttons Fix π οΈ */}
<div className='flex items-center gap-4 pt-4'>
{/* Submit Form (Looks clean!) */}
<Button
variant='primary'
size='lg'
onClick={handleSubmit}
isLoading={isSubmitting}
>
Submit Form
</Button>
<Button variant='danger' size='md' onClick={handleDelete}>
Delete Account
</Button>
<Button variant='secondary' size='sm'>
Cancel
</Button>
{/* Disabled button */}
<Button variant='primary' disabled>
Save Changes
</Button>
</div>
{/* Custom classes still work! */}
<Button variant='primary' className='w-full'>
Full Width Button
</Button>
</div>
)
}
File: app/settings/page.tsx (Same clean pattern)
'use client'
import { Button } from '@/components/ui/Button'
export default function SettingsPage() {
return (
<div className='p-8'>
<div className='flex justify-between items-center mb-6'>
{/* Our Back Button (using the New Button impementation) */}
<Button
variant='secondary'
size='sm'
onClick={() => window.history.back()}
>
Back
</Button>
<h1 className='text-2xl font-bold'>Settings</h1>
{/* Action Button (similar implimentation) */}
<Button variant='secondary' size='sm'>
<a href='/'>Go to Home</a>
</Button>
</div>
{/* --- End of New Header Row --- */}
{/* Every button looks consistent - no more typos! */}
<div className='pt-4'>
<Button variant='primary' size='lg'>
Save Settings
</Button>
</div>
</div>
)
}
Look at the difference! Every button is consistent. No copy-pasting. No typos. No inconsistencies. Just clean, reusable components.
How clsx Makes This Possible
Let me quickly explain what clsx does, because it's the secret ingredient:
import clsx from 'clsx';
// clsx intelligently merges classes
const classes = clsx(
'p-4 rounded-lg', // String: always included
{ 'bg-red-500': hasError }, // Object: only if hasError is true
{ 'text-lg': size === 'large' }, // Object: only if condition is true
customClassName // String: user overrides
);
// Result: 'p-4 rounded-lg bg-red-500' (if hasError is true)
Instead of writing messy ternaries, you pass an object where:
- Key = the CSS class
- Value = the condition (true = include it, false = skip it)
It's less than 1KB and makes your code infinitely more readable.
Why This Pattern Changes Everything
File Structure Recap:
nextjs-tailwind-variant-starter/
βββ components/
β βββ ui/
β βββ Button.tsx β All button logic lives here
βββ app/
β βββ dashboard/
β β βββ page.tsx β Just imports and uses Button
β βββ settings/
β βββ page.tsx β Just imports and uses Button
Why this is the professional approach:
Readability: New developers onboard in minutes. The styling rules are explicit, not buried in ternary chaos.
Maintainability: Need to change your primary button color across 20 components? Edit one line in the variant map. Done.
Consistency: Every button looks the same because they all use the same source of truth.
Type Safety: TypeScript ensures impossible combinations crash at compile-time. Try passing
variant="primari"(typo) and your editor screams at you.Scalability: Need 10 more variants? Add 10 lines to the map. The component code stays the same.
Your Next Steps
If you're building SaaS products with Next.js and Tailwind, this pattern isn't optionalβit's essential for writing professional code.
Try this today:
- Create
components/ui/Button.tsxwith the code above - Find your messiest button usage
- Replace it with
<Button variant="primary">Click me</Button> - Feel the relief
What's the most complex component you've cleaned up with this approach? Drop a comment belowβI'd love to hear your war stories. π
Top comments (0)