We Compile Tailwind CSS at Build Time for AI-Generated HTML (No CDN Required)
TL;DR: Our AI generates HTML with Tailwind CSS classes. But generated websites need to work without JavaScript, CDN scripts, or build tools. We built a server-side Tailwind 4 compiler that extracts classes from AI-generated HTML, compiles only the CSS that’s actually used, integrates DaisyUI themes, and injects the result as an inline <style> tag. Zero layout shift. No external dependencies. Sub-50KB CSS for any site.
The Problem: AI Writes Tailwind, But Who Compiles It?
When you tell an AI to generate a webpage, it writes Tailwind classes naturally—flex, p-4, text-center, bg-gradient-to-r from-primary to-primary/80. The AI understands utility-first CSS.
But here’s the gap: Tailwind CSS needs a compilation step. Those class names mean nothing to a browser without the corresponding CSS rules.
Most people solve this with the Tailwind CDN script:
<script src="https://cdn.tailwindcss.com"></script>
This is terrible for production:
- Blocks rendering until the script loads
- Generates ALL possible CSS (megabytes of unused rules)
- No DaisyUI support without additional config
- JavaScript required → breaks without JS
- Performance penalty → poor Core Web Vitals
We needed a different approach.
The Solution: Programmatic Tailwind 4 Compilation
Tailwind 4 introduced a programmatic compile() API. We use it server-side to compile only the classes present in our generated HTML:
import { compile } from 'tailwindcss';
export async function compileTailwindCss(
classes: string[],
options: TailwindCompilerOptions = {}
): Promise<string> {
const compiler = await getCompiler(options.includePreflight, options.includeDaisyUI);
// Only generates CSS for classes that exist in the HTML
let css = compiler.build(classes);
if (options.includeDaisyUI) {
css += '\n' + getDaisyUIThemeCss(options.daisyUITheme);
}
return options.minify ? minifyCss(css) : css;
}
The key: compiler.build(classes) only generates CSS rules for the exact classes you pass in. A page using 200 Tailwind classes gets ~15KB of CSS. Not megabytes.
Class Extraction from AI-Generated HTML
First, we extract every Tailwind class from the generated HTML:
export function extractClassesFromHtml(html: string): string[] {
const classesSet = new Set<string>();
const CLASS_ATTR_REGEX = /(?:class|className)=["']([^"']+)["']/gi;
let match;
while ((match = CLASS_ATTR_REGEX.exec(html)) !== null) {
match[1].split(/\s+/).forEach(cls => {
if (cls.trim()) classesSet.add(cls.trim());
});
}
return Array.from(classesSet);
}
This catches everything the AI might generate:
- Standard utilities:
flex,p-4,rounded-lg - Responsive variants:
md:text-base,lg:grid-cols-3 - State variants:
hover:bg-blue-600,focus:ring-2 - Arbitrary values:
w-[200px],h-[calc(100vh-80px)] - Opacity modifiers:
bg-primary/50,from-primary/80 - Negative values:
-mt-4,-translate-x-1/2
The DaisyUI Challenge
We use DaisyUI for theming—it gives us semantic color names like bg-primary, text-secondary, btn-accent. But DaisyUI extends Tailwind’s color palette, which means Tailwind needs to know about DaisyUI’s colors to compile classes like bg-primary/80.
The solution: load DaisyUI as a proper Tailwind plugin at compile time:
async function loadModule(id: string, base: string): Promise<any> {
if (id === 'daisyui') {
const daisyui = await import('daisyui');
return { path: 'daisyui', base, module: daisyui.default };
}
}
// Compile with DaisyUI plugin
const baseCss = `@import "tailwindcss";\n@plugin "daisyui";`;
const compiler = await compile(baseCss, {
base: projectRoot,
loadModule,
});
Now when the AI writes from-primary/80, Tailwind knows primary is a valid color and generates the correct opacity modifier CSS using color-mix():
.from-primary\/80 {
--tw-gradient-from: color-mix(in oklab, var(--color-primary) 80%, transparent);
}
Theme CSS Variables
DaisyUI themes are injected as CSS custom properties on the [data-theme] selector:
[data-theme="autumn"] {
--color-primary: #8C0327;
--color-secondary: #D85251;
--color-accent: #D59B6C;
--color-base-100: #F5E9DC;
}
We support 28 DaisyUI themes. The AI picks the best theme for each business during the brand strategy step—but that’s another blog post.
Plugin Styles: The Runtime Problem
We inject plugins at runtime—chatbot widgets, analytics, banners. These plugins use Tailwind classes too. But they’re added after the initial HTML is compiled.
Solution: pre-compile plugin classes at build time:
export function getPluginTailwindClasses(): string[] {
const allClasses = new Set<string>();
// Instantiate each plugin and extract its classes
const chatbotGenerator = new ChatbotGenerator('Sample Business');
const chatbotHtml = chatbotGenerator.generateWidget();
extractClassesFromHtml(chatbotHtml).forEach(cls => allClasses.add(cls));
return Array.from(allClasses).sort();
}
Since plugin HTML is deterministic (same classes every time), we can extract and compile their styles ahead of time. The compiled CSS includes everything plugins will ever need.
The Full Pipeline
AI-Generated HTML (Header + Pages + Footer)
│
├── extractClassesFromHtml() → Set of unique classes
│
Plugin HTML (Chatbot, Banner, etc.)
│
├── getPluginTailwindClasses() → Known plugin classes
│
└─── All classes combined
│
compileTailwindCss(classes, {
includeDaisyUI: true,
daisyUITheme: 'autumn',
minify: true
})
│
Inline <style> tag in final HTML
The output is a complete HTML page with zero external CSS dependencies:
<!DOCTYPE html>
<html lang="en" data-theme="autumn">
<head>
<style>
/* Server-compiled Tailwind + DaisyUI (~20-40KB minified) */
@layer utilities { ... }
[data-theme="autumn"] { --color-primary: #8C0327; ... }
</style>
<link rel="stylesheet" href="./styles.css"> <!-- Just fonts + base styles -->
</head>
CSS Cascade Layers
We use @layer base for our global styles so Tailwind utilities always win:
@layer base {
body {
font-family: var(--font-secondary);
line-height: 1.6;
}
header {
position: sticky;
top: 0;
z-index: 50;
}
}
This ensures that when Tailwind generates .z-40, it overrides the base z-index: 50 on header—because unlayered styles (Tailwind utilities in @layer utilities) beat @layer base in the cascade.
Caching
Compiling Tailwind isn’t free. We cache the compiler instance:
const compilerCache = new Map<string, Compiler>();
// Cache key includes preflight and DaisyUI flags
const cacheKey = `preflight:${includePreflight}:daisyui:${includeDaisyUI}`;
First compilation: ~200ms. Subsequent compilations with the same config: ~5ms (just compiler.build(classes)).
Results
| Metric | CDN Script | Our SSR Approach |
|---|---|---|
| CSS Size | 300KB+ (all utilities) | 15-40KB (used only) |
| First Paint | Blocked by JS | Immediate |
| Layout Shift | Yes (CSS loads async) | Zero |
| JS Required | Yes | No |
| DaisyUI Support | Manual config | Built-in |
| Core Web Vitals | Poor | Excellent |
What Made This Hard
- Opacity modifiers on DaisyUI colors.
bg-primary/80requires DaisyUI to be loaded as a plugin, not just CSS variables. Took us a week to figure out. - Plugin class extraction. Runtime plugins add HTML after compilation. Pre-extracting their classes was the only reliable solution.
- CSS Cascade Layers. Getting the specificity right between base styles, Tailwind utilities, and DaisyUI components required careful
@layermanagement.
Try It
Every website on WebZum uses server-compiled Tailwind CSS. View source on any generated site—you’ll see a clean <style> tag with only the CSS that page actually uses. No CDN. No JavaScript. No layout shift. Just fast, styled HTML.