Optimizing Tailwind CSS for React Applications: Our Journey
When we began restructuring our website, the frontend team chose the latest Next.js version paired with Tailwind CSS v4. This decision marked the beginning of an optimization journey that ultimately led us to a solution that works well for our specific use case. Why We Chose Tailwind Our design system contains numerous variables that needed consistent implementation across the site. For our team, Tailwind CSS made sense because: We could easily export design tokens and convert them to Tailwind variables using style-dictionary The ecosystem provided excellent tooling and documentation for our needs Our Struggle with Utility-First Philosophy While Tailwind's utility-first approach works well for many teams, we quickly identified challenges in our specific context: Component JSX became cluttered with lengthy class strings Readability suffered with our particularly complex components Our team found code reviews more difficult with so much styling in the markup Our Styling Journey First Approach: CSS Modules with @apply Initially, we created CSS modules for each component and used @apply directives: /* Button.module.css */ .primary { @apply bg-blue-500 hover:bg-blue-700 text-white py-2 px-4 rounded; } import styles from './Button.module.css'; const Button = () => Click Me; The CSS Modules Concerns While this approach improved our component readability significantly, we encountered several concerns that made us question this strategy: Tailwind's official recommendations The Tailwind documentation explicitly advises against heavy @apply usage It states this approach works against Tailwind's utility-first philosophy The docs suggest it can create abstraction layers that reduce maintainability Potential build performance impact We read that CSS modules might increase build times Each module requires separate processing during compilation For large applications with many components, this could add up significantly Possible interference with optimization The @apply directive might interfere with Tailwind's unused CSS removal Utilities used within @apply aren't directly visible in HTML for purging This could potentially lead to larger than necessary CSS bundles These concerns, particularly around build performance as our component library grew, pushed us to explore alternatives. Second Approach: Regular CSS Files with @apply After reading that CSS modules might impact build times, we switched to regular CSS files: /* button.css */ .primary { @apply bg-blue-500 hover:bg-blue-700 text-white py-2 px-4 rounded; } import './button.css'; const Button = () => Click Me; The Global CSS Problem This approach revealed a significant issue we hadn't anticipated. Unlike CSS modules which scope styles to specific components, these regular CSS files created global styles that were bundled into our main CSS file. The consequences were severe for our application: All component styles loaded everywhere - Even if a page only used one component, the styles for ALL components were included in the CSS bundle No tree-shaking of unused styles - Our bundler couldn't determine which styles were actually needed Rapidly growing CSS file - As we added more components, our CSS file grew disproportionately large Potential naming conflicts - Without CSS modules' automatic unique class generation, we risked style collisions This approach created an unacceptably large CSS file that would impact initial page loads and hurt our performance metrics. Users would download styles for components they'd never see. Final Decision: The Real Trade-Off After experimenting with different approaches, we realized we faced a fundamental choice between two viable options: Option 1: Inline Utility Classes const Button = () => ( Click Me ); Option 2: CSS Modules with @apply /* Button.module.css */ .primary { @apply bg-blue-500 hover:bg-blue-700 text-white py-2 px-4 rounded; } import styles from './Button.module.css'; const Button = () => Click Me; Comparing the Trade-offs Consideration Inline Utility Classes CSS Modules with @apply CSS Bundle Size Smaller (only used utilities) Slightly larger (extracted classes) HTML/JSX Size Larger (verbose class strings) Smaller (single class names) Build Time Faster Slower Component Readability Lower Higher Class Name Conflicts None None (scoped by module) Making Our Decision We analyzed these trade-offs carefully and realized that the choice came down to: Slightly smaller CSS bundle + larger HTML with inline utilities Slightly larger CSS bundle + smaller HTML with CSS modules An important insight emerged during our analysis: CSS is downloaded once and cached by the browser, while HTML is re-downloaded with each page request. Since we operate a high-traffic websi

When we began restructuring our website, the frontend team chose the latest Next.js version paired with Tailwind CSS v4. This decision marked the beginning of an optimization journey that ultimately led us to a solution that works well for our specific use case.
Why We Chose Tailwind
Our design system contains numerous variables that needed consistent implementation across the site. For our team, Tailwind CSS made sense because:
- We could easily export design tokens and convert them to Tailwind variables using style-dictionary
- The ecosystem provided excellent tooling and documentation for our needs
Our Struggle with Utility-First Philosophy
While Tailwind's utility-first approach works well for many teams, we quickly identified challenges in our specific context:
- Component JSX became cluttered with lengthy class strings
- Readability suffered with our particularly complex components
- Our team found code reviews more difficult with so much styling in the markup
Our Styling Journey
First Approach: CSS Modules with @apply
Initially, we created CSS modules for each component and used @apply
directives:
/* Button.module.css */
.primary {
@apply bg-blue-500 hover:bg-blue-700 text-white py-2 px-4 rounded;
}
import styles from './Button.module.css';
const Button = () => <button className={styles.primary}>Click Mebutton>;
The CSS Modules Concerns
While this approach improved our component readability significantly, we encountered several concerns that made us question this strategy:
-
Tailwind's official recommendations
- The Tailwind documentation explicitly advises against heavy
@apply
usage - It states this approach works against Tailwind's utility-first philosophy
- The docs suggest it can create abstraction layers that reduce maintainability
- The Tailwind documentation explicitly advises against heavy
-
Potential build performance impact
- We read that CSS modules might increase build times
- Each module requires separate processing during compilation
- For large applications with many components, this could add up significantly
-
Possible interference with optimization
- The
@apply
directive might interfere with Tailwind's unused CSS removal - Utilities used within
@apply
aren't directly visible in HTML for purging - This could potentially lead to larger than necessary CSS bundles
- The
These concerns, particularly around build performance as our component library grew, pushed us to explore alternatives.
Second Approach: Regular CSS Files with @apply
After reading that CSS modules might impact build times, we switched to regular CSS files:
/* button.css */
.primary {
@apply bg-blue-500 hover:bg-blue-700 text-white py-2 px-4 rounded;
}
import './button.css';
const Button = () => <button className="primary">Click Mebutton>;
The Global CSS Problem
This approach revealed a significant issue we hadn't anticipated. Unlike CSS modules which scope styles to specific components, these regular CSS files created global styles that were bundled into our main CSS file.
The consequences were severe for our application:
- All component styles loaded everywhere - Even if a page only used one component, the styles for ALL components were included in the CSS bundle
- No tree-shaking of unused styles - Our bundler couldn't determine which styles were actually needed
- Rapidly growing CSS file - As we added more components, our CSS file grew disproportionately large
- Potential naming conflicts - Without CSS modules' automatic unique class generation, we risked style collisions
This approach created an unacceptably large CSS file that would impact initial page loads and hurt our performance metrics. Users would download styles for components they'd never see.
Final Decision: The Real Trade-Off
After experimenting with different approaches, we realized we faced a fundamental choice between two viable options:
Option 1: Inline Utility Classes
const Button = () => (
<button className="bg-blue-500 hover:bg-blue-700 text-white py-2 px-4 rounded">
Click Me
button>
);
Option 2: CSS Modules with @apply
/* Button.module.css */
.primary {
@apply bg-blue-500 hover:bg-blue-700 text-white py-2 px-4 rounded;
}
import styles from './Button.module.css';
const Button = () => <button className={styles.primary}>Click Mebutton>;
Comparing the Trade-offs
Consideration | Inline Utility Classes | CSS Modules with @apply
|
---|---|---|
CSS Bundle Size | Smaller (only used utilities) | Slightly larger (extracted classes) |
HTML/JSX Size | Larger (verbose class strings) | Smaller (single class names) |
Build Time | Faster | Slower |
Component Readability | Lower | Higher |
Class Name Conflicts | None | None (scoped by module) |
Making Our Decision
We analyzed these trade-offs carefully and realized that the choice came down to:
- Slightly smaller CSS bundle + larger HTML with inline utilities
- Slightly larger CSS bundle + smaller HTML with CSS modules
An important insight emerged during our analysis: CSS is downloaded once and cached by the browser, while HTML is re-downloaded with each page request. Since we operate a high-traffic website with many repeat visitors, reducing HTML size would benefit user experience more than minimizing CSS size.
Given this context, we decided to return to CSS modules with selective @apply
usage, accepting the trade-off of slightly longer build times and marginally larger CSS for the benefits of smaller HTML payloads and improved code maintainability.
Conclusion
This was the right decision for our specific needs, though we recognize other teams with different requirements might make different choices. Tailwind is flexible enough to support various implementation styles, and we chose what works best for our specific needs, team preferences, and user base.
Our journey helped us understand that there's no one-size-fits-all approach to CSS architecture even within the Tailwind ecosystem. What matters most is making intentional decisions based on your specific constraints and priorities.