What Changed in Tailwind v4
Tailwind CSS v4 is a ground-up rewrite that fundamentally changes how the framework integrates with your build pipeline. The tailwind.config.js file is gone, configuration now lives directly in your CSS using @theme directives and CSS custom properties. The familiar @tailwind base; @tailwind components; @tailwind utilities; directives are replaced by a single @import "tailwindcss" statement. It's a meaningful philosophical shift: your CSS file becomes the single source of truth for both framework configuration and custom styling.
Content detection, the mechanism that determines which utility classes end up in your output, is now automatic for JavaScript and TypeScript files. But Liquid templates aren't scanned by default, and that's where @source directives become essential for Shopify theme work. The new Oxide engine, written in Rust, delivers build times roughly 10x faster than v3. For standard projects, v4 is genuinely zero-config: import Tailwind, write classes, and the engine handles the rest.
Project Structure
Before writing any configuration, you need the right directory layout. The frontend/ directory sits alongside your standard Shopify theme folders and contains everything Vite processes during builds. Entry points go in frontend/entrypoints/, each file there becomes a separate output bundle that you can load on specific pages or globally. Here's the structure you're working toward:
your-theme/
├── frontend/
│ ├── entrypoints/
│ │ ├── theme.css ← Main CSS entry (Tailwind + custom)
│ │ └── theme.js ← Main JS entry
│ └── styles/
│ └── components.css ← Custom component styles
├── sections/
├── snippets/
├── templates/
├── layout/
├── assets/ ← Vite build output
├── vite.config.js
├── package.json
└── .shopifyignore
The critical detail: Vite's build output goes into your theme's assets/ directory. Shopify serves everything in assets/ through its CDN, so your compiled CSS and JavaScript automatically get edge-cached and delivered to customers worldwide. The frontend/ directory never gets uploaded to Shopify, it's purely a build-time concern.
Vite Configuration
The Vite configuration is minimal thanks to vite-plugin-shopify, which handles entry point discovery, asset path generation, and the dev/production toggle automatically:
import { defineConfig } from 'vite';
import shopify from 'vite-plugin-shopify';
export default defineConfig({
plugins: [shopify()],
build: {
emptyOutDir: false,
},
});
The emptyOutDir: false setting is critical and easy to overlook. By default, Vite clears the entire output directory before every build. In a Shopify theme, that means it would delete everything in assets/, including fonts, images, and CSS injected by third-party apps like Klaviyo, ReCharge, or Judge.me. One production build without this flag and your store's app integrations silently break. Always set it to false.
Why so little config? vite-plugin-shopify automatically discovers entry points in frontend/entrypoints/, generates the vite-tag Liquid snippet, and handles the dev server / production asset path switching. You rarely need to configure Rollup options manually.
The CSS Entry Point
This is where Tailwind v4 configuration happens, entirely in CSS. No separate config file, no JavaScript theme object. Everything the framework needs to know about your design tokens, content sources, and component styles lives in a single entry point:
@import "tailwindcss";
/* Tell Tailwind to scan Liquid files for class names */
@source "../../sections/*.liquid";
@source "../../snippets/*.liquid";
@source "../../templates/**/*.liquid";
@source "../../layout/*.liquid";
/* Custom theme tokens, replace tailwind.config.js */
@theme {
--color-brand: #8c6144;
--color-brand-light: #b89070;
--color-brand-dark: #6a4830;
--font-family-heading: 'Poppins', sans-serif;
--font-family-body: 'Inter', sans-serif;
--breakpoint-xs: 475px;
--spacing-container: clamp(1rem, 5vw, 3rem);
}
/* Custom components */
@import "../styles/components.css";
@import "tailwindcss" replaces the old @tailwind base; @tailwind components; @tailwind utilities; directives. This single import loads the entire framework, base reset, component layer, and utility layer, in one statement.
@source directives tell Tailwind where to find class usage in non-JS files. Without them, Tailwind's automatic content detection only scans JavaScript and CSS. For Shopify themes, these directives are how you connect your Liquid templates to the CSS build. The paths are relative to the CSS file's location, which is why they start with ../../, going up two directories from frontend/entrypoints/ to the theme root.
@theme replaces the theme.extend block from tailwind.config.js. Custom properties defined here become first-class Tailwind utilities, --color-brand generates bg-brand, text-brand, border-brand, and so on. --font-family-heading generates font-heading. The naming convention follows the CSS custom property name after the category prefix.
The most common setup mistake. If you forget the @source directives, Tailwind's automatic content detection only scans JavaScript and CSS files. Your Liquid templates will be ignored, and any utility classes used only in .liquid files will be missing from the output. The CSS builds without errors, it's just incomplete.
The @source Directive Deep Dive
The @source directive accepts glob patterns relative to the CSS file. You can be as broad or as targeted as your project needs:
/* Scan ALL Liquid files at once */
@source "../../**/*.liquid";
/* Or target specific directories for faster builds */
@source "../../sections/*.liquid";
@source "../../snippets/*.liquid";
@source "../../templates/**/*.liquid";
@source "../../layout/*.liquid";
/* Also scan JSON templates (used by Online Store 2.0) */
@source "../../templates/**/*.json";
The more targeted approach is faster because Tailwind scans fewer files, but the broad **/*.liquid pattern is perfectly fine for most themes. The Oxide engine is fast enough that the difference is negligible unless your theme has hundreds of template files.
Where @source cannot help is with dynamically constructed class names. Tailwind scans files as static text, it has no understanding of Liquid's runtime logic. If you concatenate class names at render time, the scanner won't find them:
{%- comment -%} Tailwind CANNOT detect this at build time {%- endcomment -%}
{{ 'bg-' | append: section.settings.color }}
{%- comment -%} Use complete class strings instead {%- endcomment -%}
{%- case section.settings.color -%}
{%- when 'red' -%} <div class="bg-red-500">
{%- when 'blue' -%} <div class="bg-blue-500">
{%- endcase -%}
The first pattern, {{ 'bg-' | append: section.settings.color }}, produces a valid class at runtime, but Tailwind's scanner sees the string bg- and a Liquid variable, not bg-red-500. The second pattern uses complete class strings that the scanner can find in the source text, even though they're inside conditional branches that may not all execute.
Safelisting dynamic classes. If you must generate dynamic class names in Liquid, add a safelist in your CSS. In Tailwind v4, use @source inline(utilities) to force-include specific utilities, or add hidden elements in your Liquid with the classes you need, the scanner doesn't care whether an element is visible.
Package.json and Build Scripts
The build scripts coordinate Vite's asset pipeline with Shopify CLI's theme server. Here's the full package.json:
{
"name": "your-shopify-theme",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"dev:shopify": "shopify theme dev --store your-store.myshopify.com",
"dev:all": "concurrently \"npm run dev\" \"npm run dev:shopify\"",
"deploy": "vite build && shopify theme push",
"deploy:safe": "vite build && shopify theme push --nodelete"
},
"devDependencies": {
"vite": "^6.0.0",
"vite-plugin-shopify": "^4.0.0",
"tailwindcss": "^4.0.0",
"concurrently": "^9.0.0"
}
}
dev starts the Vite dev server with HMR on localhost:5173. CSS and JS changes inject instantly without a page reload. build produces the production output, minified, tree-shaken, content-hashed files in assets/.
dev:shopify starts the Shopify CLI dev server, which creates a secure tunnel to your development store and watches for Liquid file changes. dev:all runs both servers concurrently, this is the command you'll use most during development.
deploy builds production assets and pushes the entire theme to Shopify. deploy:safe does the same but with the --nodelete flag, which prevents Shopify from removing remote files that don't exist locally. Use deploy:safe when your store has app-injected assets or files uploaded through the theme editor that aren't in your local codebase.
.shopifyignore
The .shopifyignore file prevents build-time source files from being uploaded to Shopify. Without it, shopify theme push would try to upload your entire frontend/ directory and node_modules/, neither of which Shopify needs or wants:
frontend/
node_modules/
package.json
package-lock.json
vite.config.js
.env
.gitignore
The HMR Development Workflow
The development experience with Vite and Tailwind v4 in a Shopify theme runs on two parallel servers, and understanding what each does saves you debugging time.
Run npm run dev:all to start both servers. Vite launches on localhost:5173 and watches everything in frontend/. The Shopify CLI dev server creates a tunnel to your development store and watches Liquid, JSON, and asset files. When you add a Tailwind class to a Liquid file, Vite detects the change via the @source paths, regenerates only the affected CSS, and injects it via HMR, no page reload. JavaScript changes also hot-reload when structured as ESM modules. Liquid template changes themselves trigger a full page reload via the Shopify CLI server, since Liquid is rendered server-side.
The vite-tag snippet is how your Liquid layout references Vite-managed assets:
<head>
{% render 'vite-tag' with 'theme.css' %}
{% render 'vite-tag' with 'theme.js' %}
</head>
In development, vite-tag points to the Vite dev server (localhost:5173) so HMR works. In production, when no dev server is running, it automatically falls back to the compiled assets in assets/. This switch is handled entirely by the snippet that vite-plugin-shopify generates. You don't need conditional logic in your layout.
HMR scope. Hot module replacement only applies to CSS and JavaScript changes processed by Vite. Liquid file edits, section schema changes, and JSON template updates all trigger full page reloads through the Shopify CLI dev server. This is a Shopify limitation, Liquid is rendered server-side and can't be hot-patched on the client.
GitHub Actions Build Pipeline
If your team uses Git-based deployments, you need a CI step to compile Vite assets before pushing to Shopify. This GitHub Actions workflow builds on every push to staging and commits the compiled output back to the branch:
name: Build theme assets
on:
push:
branches: [staging]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci
- name: Build assets
run: npm run build
- name: Commit compiled assets
run: |
git config --global user.name 'github-actions[bot]'
git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add assets/
git commit -m 'Auto-build: compiled Vite assets' || echo "No changes"
git push origin staging
The workflow uses npm ci instead of npm install for deterministic installs from the lockfile. After building, it commits only the assets/ directory, the compiled output that Shopify actually serves. The || echo "No changes" guard prevents the workflow from failing when the build output hasn't changed between commits.
Production Build Output
Running npm run build produces optimized assets that Shopify serves through its CDN. Vite generates tree-shaken CSS containing only the Tailwind utilities your templates actually use, minified and autoprefixed. Filenames include content hashes for aggressive cache busting, when your CSS changes, the filename changes, so browsers always fetch the latest version.
The size difference compared to traditional approaches is significant:
- Tailwind v4 with @source: 8–15 KB gzipped (only used utilities)
- Tailwind v3 with purge: 20–40 KB gzipped
- Bootstrap 5: 60–80 KB gzipped
- Unpurged Tailwind: 300+ KB gzipped (every utility included)
The improvement from v3 to v4 comes from the Oxide engine's more aggressive dead-code elimination and the fact that @source scanning is more precise than v3's regex-based content matching. You get a smaller output with fewer false positives, classes that looked like they might be used but weren't.
Migrating from Tailwind v3
If you're upgrading an existing Shopify theme from Tailwind v3, the migration touches configuration files rather than your templates. Your HTML and Liquid files with Tailwind classes don't need to change, the utility class names are the same. Here's what the configuration shift looks like.
Before, Tailwind v3:
module.exports = {
content: [
'./sections/*.liquid',
'./snippets/*.liquid',
'./templates/**/*.liquid',
'./layout/*.liquid',
],
theme: {
extend: {
colors: {
brand: '#8c6144',
'brand-light': '#b89070',
'brand-dark': '#6a4830',
},
fontFamily: {
heading: ['Poppins', 'sans-serif'],
body: ['Inter', 'sans-serif'],
},
},
},
}
@tailwind base;
@tailwind components;
@tailwind utilities;
After, Tailwind v4:
@import "tailwindcss";
@source "../../sections/*.liquid";
@source "../../snippets/*.liquid";
@source "../../templates/**/*.liquid";
@source "../../layout/*.liquid";
@theme {
--color-brand: #8c6144;
--color-brand-light: #b89070;
--color-brand-dark: #6a4830;
--font-family-heading: 'Poppins', sans-serif;
--font-family-body: 'Inter', sans-serif;
}
The key migration steps:
- Delete
tailwind.config.js, or keep it temporarily with@config "./tailwind.config.js"in your CSS for gradual migration - Replace
@tailwind base; @tailwind components; @tailwind utilities;with@import "tailwindcss" - Move
content: [...]paths to@sourcedirectives in your CSS file - Move
theme.extendcustomizations to@theme { }using CSS custom properties - Replace custom plugins with the
@plugindirective or plain CSS using@utilityand@variant
Gradual migration. You don't have to migrate everything at once. Adding @config "./tailwind.config.js" to your v4 CSS file tells Tailwind to load your existing JavaScript config alongside the new CSS-based configuration. This lets you migrate incrementally, move one section at a time from the JS config to @theme and @source directives.
Edge Cases and Production Concerns
Dynamic class generation in Liquid. Tailwind cannot detect classes constructed at runtime through string concatenation. Use complete class strings in conditional branches, or safelist the dynamic classes using @source inline(utilities). This is a fundamental limitation of any utility-first CSS framework with tree-shaking, it applies equally to v3 and v4.
Theme editor live preview. The Shopify theme editor loads your theme in an iframe and may not connect to your local Vite dev server. Always test with shopify theme dev rather than relying on the admin theme editor for CSS changes. Production-deployed themes work fine in the editor since they use the compiled assets.
Third-party app CSS conflicts. Apps like Klaviyo, Yotpo, or ReCharge inject their own CSS, sometimes including Tailwind utilities. If an app ships Tailwind v3 classes that conflict with your v4 output, use Tailwind's @layer or increase specificity with a wrapper selector. The important strategy (@import "tailwindcss" important;) is a last resort.
emptyOutDir: false. Mentioned earlier, but worth repeating: always set this in your Vite config. Without it, builds delete app-injected assets, uploaded fonts, and any file in assets/ that Vite didn't produce. This causes silent breakage that's hard to diagnose in production.
Multiple CSS entry points. You can have separate entry points for different pages, collection.css, product.css, blog.css, each with its own @source configuration scanning only the relevant templates. This reduces per-page CSS size further, though the gains are marginal for most themes given how small Tailwind v4's output already is.
PostCSS plugins. Tailwind v4 includes PostCSS processing and autoprefixing internally. If you need additional PostCSS plugins (like postcss-nesting or postcss-custom-media), configure them through Vite's css.postcss option rather than a standalone postcss.config.js, this keeps the plugin pipeline in one place.
Key Takeaways
- Tailwind v4 moves all configuration into CSS,
@import,@source, and@themereplacetailwind.config.jsentirely @sourcedirectives are mandatory for Shopify themes because Liquid files aren't scanned by defaultvite-plugin-shopifyhandles entry point discovery, asset path switching, and thevite-tagsnippet, minimal Vite config required- Always set
emptyOutDir: falseto protect app-injected and uploaded assets in your theme'sassets/directory - The dual-server workflow (
npm run dev:all) gives you HMR for CSS/JS while Shopify CLI handles Liquid reloads - Production output lands at 8–15 KB gzipped, a 50–70% reduction from Tailwind v3 with purge
- Migration from v3 is config-level only, your existing Tailwind classes in Liquid templates don't change
If your Shopify theme needs a modern CSS build pipeline with Tailwind v4 and Vite, or you're migrating from an older setup and want it done right, I can help you architect the workflow. Let's talk about what your theme actually needs.