Tailwind CSS gets a lot of criticism for producing unmaintainable utility soup in large codebases. After using it across multiple React and Next.js projects — including a digital card SaaS platform serving 10,000+ users with 50+ card templates — I've landed on patterns that keep Tailwind clean, consistent, and refactorable at scale.
The actual problem with Tailwind at scale
The problem isn't Tailwind — it's treating every element as a one-off. Writing className strings directly into every JSX element means the same button appears in 40 files with slightly different class combinations. When the design system changes, you're doing a grep-and-fix across the entire codebase. The fix: treat Tailwind as a design token system, not just CSS shorthand, and enforce abstraction boundaries.
Pattern 1: Component variants with cva
class-variance-authority (cva) is the cleanest solution for managing component variants. Define a variant map once per component, compose with cx() for conditional classes, and call it with props. No ternary className chains, no string interpolation. This is exactly how shadcn/ui works under the hood — it's the community-accepted standard.
import { cva, type VariantProps } from 'class-variance-authority';
const button = cva(
// base classes always applied
'inline-flex items-center justify-center rounded-full font-semibold transition-all duration-200 focus:outline-none focus:ring-2',
{
variants: {
variant: {
primary: 'bg-cyan-400 text-gray-900 hover:bg-white shadow-lg shadow-cyan-400/20',
outline: 'border border-white/20 text-white hover:border-cyan-400 hover:text-cyan-400',
ghost: 'text-gray-400 hover:text-white hover:bg-white/5',
},
size: {
sm: 'px-4 py-2 text-sm gap-1.5',
md: 'px-6 py-3 text-base gap-2',
lg: 'px-8 py-4 text-lg gap-2.5',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
);
type ButtonProps = VariantProps<typeof button> & React.ButtonHTMLAttributes<HTMLButtonElement>;
export function Button({ variant, size, className, ...props }: ButtonProps) {
return <button className={button({ variant, size, className })} {...props} />;
}Pattern 2: Design tokens in tailwind.config
Never hardcode brand colours, font sizes, or spacing values directly in components — always extend tailwind.config.ts. When the brand's primary colour changes, you update one line. When the design team introduces a new spacing scale, it flows through automatically. For the card platform, I extended config with 12 brand colours, 3 font families, and a custom shadow scale that matched the Figma design system exactly.
export default {
content: ['./src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
brand: {
primary: '#3b82f6',
secondary: '#3b82f6',
accent: '#3b82f6',
},
surface: {
DEFAULT: 'rgba(255,255,255,0.04)',
hover: 'rgba(255,255,255,0.08)',
},
},
fontFamily: {
display: ['Plus Jakarta Sans', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
boxShadow: {
glow: '0 0 24px rgba(59,130,246,0.2)',
'glow-lg': '0 0 48px rgba(59,130,246,0.15)',
},
},
},
} satisfies Config;Pattern 3: The abstraction boundary rule
The rule that keeps large Tailwind codebases maintainable: page-level code should rarely touch Tailwind utility classes directly. Pages compose primitive components (Button, Card, Badge, Input, Avatar). Primitive components own their Tailwind strings. Layout components (Section, Container, Grid) own their spacing and responsive classes. This means when a design change comes in, you update one component file — not 30 pages.
Key insight: Tailwind at scale is an architecture problem, not a CSS problem. Build a component library of ~15–20 primitives that encapsulate all the Tailwind strings. Compose them everywhere. Your page-level JSX should read like a design document — component names, not utility strings.