Frameworks
Tailwind CSS Intermediate/Advanced
Chapter 5 · Tailwind in Frameworks
So far every build used the Tailwind command line. Real projects run Tailwind inside a bundler or a framework, and the integration has the same three jobs everywhere: read your CSS entry file, scan the project for classes, and put the result in the page. This chapter wires Tailwind into seven toolchains, runs each one for real, and then deals with the problems that appear once it is in: style blocks in components, a flash of the wrong theme when the server renders the page, and shared code in a monorepo.
@sveltejs/adapter-static 4.0.0 and Svelte 5.57.2,
React Router 8.4.0, Vue 3.5.43 with @vitejs/plugin-vue 6.0.10, webpack 5.111.1. I ran production builds for all of them and the development server for Vite and Next.js. Not run:
Nuxt, Remix and other frameworks, Rspack, Parcel, the other development servers, real deployments, and any browser except Chrome 154. Where I name something I did not run, it says so.
Which Package for Which Tool
| Your tool | Install | Configuration I ran |
|---|---|---|
| Vite, and anything built on it (Astro, SvelteKit, React Router, Vue) | @tailwindcss/vite | plugins: [tailwindcss()] in the Vite config (Astro: inside the vite key) |
| Next.js, and other PostCSS-based tools | @tailwindcss/postcss | plugins: { "@tailwindcss/postcss": {} } in postcss.config.mjs |
| webpack | @tailwindcss/webpack | a loader after css-loader (below) |
| Nothing (static HTML, server templates) | @tailwindcss/cli | chapter 1 of this course |
In every case the CSS entry file is the same one line, @import "tailwindcss";, plus your @theme. In every case I built a small page with a few text utilities and checked
that the built CSS contained the rules. Each of the seven worked on the first correct configuration; the surprises are below.
Vite
Two lessons. A production build minifies the CSS without being asked (the CLI needed --minify, chapter 1). And the development server scans your HTML as you edit it, so a new class appears without
restarting anything. I stopped the server by finding the process listening on the port and ending only that one.
Astro, Next.js, SvelteKit and React Router
These four use the same two packages in four different places. All four built without errors:
| Framework | Where the one line goes | Built CSS (my test page) |
|---|---|---|
| Astro 7.3.8 | astro.config.mjs: vite: { plugins: [tailwindcss()] }; the page imports ../styles/global.css in its frontmatter | 6,295 B, includes the page's own <style> block |
| Next.js 16.4.0 | postcss.config.mjs as above; app/layout.js imports ./globals.css | 4,855 B, linked as /_next/static/chunks/….css |
| SvelteKit 3.0.1 | vite.config.js: plugins: [tailwindcss(), sveltekit({ adapter: adapter() })]; +layout.svelte imports ../app.css | 4,956 B global, plus a 264 B file for the component's style block |
| React Router 8.4.0 (SPA mode) | vite.config.ts: plugins: [tailwindcss(), reactRouter()]; root.tsx imports ./app.css | 4,802 B, linked from build/client/index.html |
(The sizes are for different pages, so do not compare them with each other; they only show that real CSS came out.) Three things I found by running them rather than reading about them:
- SvelteKit 3 no longer reads
svelte.config.js. My first attempt used the file I remembered and the build stopped withSvelteKit error: config_file_unsupported: the message says to pass the configuration to thesveltekit(...)plugin in the Vite config, which is what the table shows. Order in the plugin list: I puttailwindcss()beforesveltekit(); I did not test the reverse. - Next.js wrote the theme colour twice. The same
--color-brand: oklch(0.55 0.2 260)came out asoklch(55% .2 260)in Vite, Astro and SvelteKit, but in the Next.js build as#1c69e3followed by@supports (color:lab(0% 0 0)) { :root,:host { --color-brand: lab(45.7354% 14.1968 -69.775) } }. The two tools use different browser targets when they post-process the CSS. I did not trace the setting that causes it; if exact colour matters across tools, compare their built output. - Next.js warned about my folder layout. It printed
We detected multiple lockfiles and selected the directory of C:\lwdj\tw2\package-lock.json as the root directory, because my scratch project sat inside another one. That is a monorepo question in disguise; see the last section.
webpack
The webpack integration is a loader, not a plugin. It goes last in the use list (loaders run right to left), after css-loader and the extraction plugin:
The loader scans from the working directory, so run webpack from the project root (or set base). The page's classes were found in src/index.js without any extra configuration.
Component Styles: @apply and @reference
The most common v4 integration error comes from the framework's own style blocks (Vue, Svelte, Astro) and CSS modules. Each block is compiled as its own stylesheet, so it cannot see your theme or utilities. This is what happened in a Vue single-file component:
The fix is @reference, which imports your main stylesheet for use by @apply without copying any of it into the output. I measured the alternatives with the same component:
| Version of the component | Result | Output CSS |
|---|---|---|
@apply, nothing else | build fails | – |
+ @reference "./style.css"; | works | 4,755 B |
@import "tailwindcss"; inside the component | works, but repeats the whole reset (box-sizing rule 4 times instead of 2) | 10,054 B |
no @apply: background: var(--color-brand) and friends | works with no @reference at all | 4,463 B |
| no style block: utilities in the template | works | 4,601 B |
The same error and the same fix applied to a CSS module in plain Vite (._btn_16twv_2{…} came out). Astro and SvelteKit style blocks with @reference "../styles/global.css" built
and produced .btn[data-astro-cid-…] and .btn.svelte-… rules; the relative path is from the component, and my first attempt with the wrong depth failed with Can't resolve.
<style> blocks in Vue, Svelte and Astro components, because each block is processed separately (so Tailwind runs once per block), and prefers plain CSS variables over @apply. The sizes above agree. Use utility classes in the template and keep component style blocks for what utilities cannot do. Theme values are ordinary CSS
variables (var(--color-brand), calc(var(--spacing) * 4)), and those work in any style block with no setup. Reach for @apply plus @reference only when you must, and
never import all of Tailwind into each component.
Server Rendering and the Flash of the Wrong Theme
Chapter 2 switched themes with a data-mode attribute. If the server renders the page and your application code sets the attribute later, a visitor who chose dark sees a light page first. I measured
it with three pages that differ only in when the attribute is set, saved preference "dark", sampling the background on every animation frame:
The answer is a small classic script in the document head that sets the attribute before the page paints. The module version was deferred by the browser even though it was inline, so its first frame was light. How that looks in two frameworks I built:
Astro rewrote my plain <script> into <script type="module">, which is the deferred kind, so use is:inline to keep it a classic script. In Next.js, a script that changes
<html> before React hydrates makes the server HTML and the browser's disagree, and React says so; suppressHydrationWarning on that one element is the intended answer and it
removed the message. I did not run SvelteKit (the equivalent place is the inline script in src/app.html) or React Router (a script in the root layout) for this, so treat those two as untested.
Monorepos and Shared Theme Packages
A monorepo splits code into packages: here a theme package (tokens), a ui package (components that carry Tailwind classes) and an app that uses both through npm workspaces. Two things
break silently, and I measured both with the same built app:
Version 1: the shared button's classes live outside the app's folder and the scanner does not follow the workspace link in node_modules, so none of them were generated. Version 3: without the theme
import the tokens do not exist, so even the app's own text-accent disappeared. Neither raised an error. The rules: add @source for every workspace package whose files contain classes (the
path is relative to the CSS file), and import the shared theme CSS in every app, once. The theme package is just a CSS file exposed through the package's exports, which makes it a convenient place to put
the semantic tokens from chapter 2 for several apps.
svelte.config.js approach is rejected; the error message told me the new place. How to avoid it: run the version you are
teaching before writing its configuration.2. A wrong relative path in
@reference. I wrote ../style.css from a file that was already beside style.css. How to avoid it: read the "Can't resolve" line; it names the folder it searched.3. My count of a class gave a false zero. The pattern
hover.bg-accent could not match hover\:bg-accent (two characters between). How to avoid it: when a count is zero where
you expect one, print the matching line from another angle before concluding it is missing.4. I nearly compared CSS sizes across different pages. The framework builds contain different markup, so their sizes mean nothing side by side; only the component-style comparison (same component, five versions) is a fair one. How to avoid it: change one thing at a time.
Hands-On Exercises
Make three pages that apply a saved dark preference by a late script, a classic inline script in the head, and an inline module script. Sample the page background on every animation frame for 400 ms with the preference set to dark and report when each page turns dark. Then say which of the three your framework's layout should use.
📄 View solutionIn a Vue (or Svelte or Astro) project, put @apply bg-brand text-white px-4 py-2 in a component style block. Show the error, fix it with @reference, then measure the CSS for: the fix, importing tailwindcss inside the component, plain var() theme values with no @apply, and utilities in the template.
Build an npm-workspaces monorepo with a theme package, a UI package and an app. Produce the three results table: no @source, theme plus @source, and @source without the theme import. Explain in each case what is missing and why nothing complained.
Chapter 5 Quick Reference
- Vite-based tools (Vite, Astro, SvelteKit, React Router, Vue):
@tailwindcss/vite,plugins: [tailwindcss()]. Next.js / PostCSS:@tailwindcss/postcss. webpack: the@tailwindcss/webpackloader (optionsbase,optimize). No bundler: the CLI - CSS entry in every case:
@import "tailwindcss";plus your@theme; import it once from the app's root - Vite builds minify automatically; the dev server picks up new classes without a restart
- SvelteKit 3: configuration goes into
sveltekit({ … })in the Vite config, notsvelte.config.js - Component style blocks and CSS modules cannot see Tailwind:
@reference "./style.css";(path relative to the component) before@apply; never@import "tailwindcss"in each component (10,054 B against 4,755 B) - Theme values are plain variables:
var(--color-brand)works in any style block without@reference - No-flash theme: a classic inline script in the head sets
data-modebefore paint. Astro:is:inline. Next.js:dangerouslySetInnerHTMLin<head>andsuppressHydrationWarningon<html>. Module scripts are deferred - Monorepo:
@source "../../../packages/ui"for each package with classes (relative to the CSS file) and@import "@acme/theme/theme.css"in each app. Both failures are silent - Build tools post-process the CSS differently: the same token came out as
oklch()in Vite and as#hexplus alab()override in Next.js
cva for typed variants, clsx and tailwind-merge, headless libraries (Radix, Headless UI, React Aria) styled with Tailwind, shadcn/ui, daisyUI
and Flowbite compared, and when a plain component beats all of it. Start with Tailwind CSS Intermediate/Advanced 6.