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.

Every example here was run
Tailwind 4.3.3 everywhere, on Node 24.18.0 and Windows 11. Versions installed: Vite 8.3.4, Astro 7.3.8, Next.js 16.4.0 (Turbopack), SvelteKit 3.0.1 with @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 toolInstallConfiguration I ran
Vite, and anything built on it (Astro, SvelteKit, React Router, Vue)@tailwindcss/viteplugins: [tailwindcss()] in the Vite config (Astro: inside the vite key)
Next.js, and other PostCSS-based tools@tailwindcss/postcssplugins: { "@tailwindcss/postcss": {} } in postcss.config.mjs
webpack@tailwindcss/webpacka loader after css-loader (below)
Nothing (static HTML, server templates)@tailwindcss/clichapter 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

// vite.config.js import { defineConfig } from 'vite'; import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [tailwindcss()] }); /* src/style.css */ @import "tailwindcss";
vite build exit 0 dist/assets/index-CtRtC2ic.css 4.85 kB text-3xl, font-bold, underline, text-sky-600: each present in the file; the file is already minified (starts @layer properties{@supports …) vite dev (port 5199): GET /src/style.css -> 6,118 bytes of CSS-as-a-module, contains text-sky-600, no bg-red-500 edit index.html to add bg-red-500, request again (no restart): bg-red-500 now present (1 occurrence)

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:

FrameworkWhere the one line goesBuilt CSS (my test page)
Astro 7.3.8astro.config.mjs: vite: { plugins: [tailwindcss()] }; the page imports ../styles/global.css in its frontmatter6,295 B, includes the page's own <style> block
Next.js 16.4.0postcss.config.mjs as above; app/layout.js imports ./globals.css4,855 B, linked as /_next/static/chunks/….css
SvelteKit 3.0.1vite.config.js: plugins: [tailwindcss(), sveltekit({ adapter: adapter() })]; +layout.svelte imports ../app.css4,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.css4,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 with SvelteKit error: config_file_unsupported: the message says to pass the configuration to the sveltekit(...) plugin in the Vite config, which is what the table shows. Order in the plugin list: I put tailwindcss() before sveltekit(); 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 as oklch(55% .2 260) in Vite, Astro and SvelteKit, but in the Next.js build as #1c69e3 followed 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:

module: { rules: [{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader', { loader: '@tailwindcss/webpack', options: { optimize: { minify: true } } }], }] },
without options: main.css 5,362 bytes (readable, 191 lines) optimize: { minify: true }: main.css 4,770 bytes the options in the type definitions: base (directory to scan, default the working directory), optimize (boolean or { minify })

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:

<style scoped> .btn { @apply bg-brand text-white px-4 py-2; } </style>
exit 1 Error: Cannot apply unknown utility class `bg-brand`. Are you using CSS modules or similar and missing `@reference`?

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 componentResultOutput CSS
@apply, nothing elsebuild fails–
+ @reference "./style.css";works4,755 B
@import "tailwindcss"; inside the componentworks, but repeats the whole reset (box-sizing rule 4 times instead of 2)10,054 B
no @apply: background: var(--color-brand) and friendsworks with no @reference at all4,463 B
no style block: utilities in the templateworks4,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.

What to do
Tailwind's own Compatibility page recommends avoiding <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:

flash.html late script (150 ms), standing in for a framework applying it after it loads noflash.html classic inline <script> in <head>, before the stylesheet module.html the same script with type="module"
flash.html: 0 ms oklch(0.99 0 0) light 175 ms oklch(0.2 0.02 260) dark <- visible flash noflash.html: 0 ms oklch(0.2 0.02 260) dark from the first sample module.html: 0 ms oklch(0.99 0 0) light 1 ms oklch(0.2 0.02 260) dark <- deferred

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 --- <script>document.documentElement.dataset.mode = localStorage.getItem('mode') || 'light';</script> <script is:inline>document.documentElement.dataset.mode = localStorage.getItem('mode') || 'light';</script> --- Next.js (app/layout.js) --- <html lang="en" suppressHydrationWarning> <head><script dangerouslySetInnerHTML={{ __html: init }} /></head>
Astro build, <head> of dist/index.html: <script type="module">document.documentElement.dataset.mode=localStorage.getItem(`mode`)||`light`;</script> <- from the plain <script> <script>document.documentElement.dataset.mode = localStorage.getItem('mode') || 'light';</script> <- from is:inline <link rel="stylesheet" href="/_astro/index.DAT-mcAi.css"> Next.js dev server, saved mode dark, console after load: script in <head>, no suppressHydrationWarning: error: A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up… with suppressHydrationWarning on <html>: no error; data-mode on <html> = dark

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:

packages/theme/theme.css: @theme { --color-brand: …; --color-accent: …; } packages/ui/index.js: export const button = 'rounded-lg bg-brand px-4 py-2 text-white hover:bg-accent'; apps/web/src/style.css (three versions): 1: @import "tailwindcss"; @import "@acme/theme/theme.css"; 2: …as 1, plus @source "../../../packages/ui"; 3: @import "tailwindcss"; @source "../../../packages/ui"; (theme import forgotten)
1 (no @source) 2 (theme + @source) 3 (@source, no theme) bg-brand 0 1 0 text-white 0 1 1 px-4 0 1 1 hover:bg-accent - 1 - text-accent (app's own) 1 1 0 --color-brand variable absent oklch(55% .2 260) absent

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.

Mistakes while preparing this chapter
1. I wrote SvelteKit 3 configuration from memory. The old 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

Exercise 1

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 solution
Exercise 2

In 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.

📄 View solution
Exercise 3

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.

📄 View solution

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/webpack loader (options base, 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, not svelte.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-mode before paint. Astro: is:inline. Next.js: dangerouslySetInnerHTML in <head> and suppressHydrationWarning on <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 #hex plus a lab() override in Next.js
Coming next
Chapter 6 turns to component patterns: 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.