/ astro-integrations / How to Integrate Vanilla Extract with Astro: Complete Guide
astro-integrations 10 min read

How to Integrate Vanilla Extract with Astro: Complete Guide

Step-by-step guide to integrating Vanilla Extract with your Astro website.

How to Integrate Vanilla Extract with Astro: Complete Guide

Vanilla Extract is a CSS-in-TypeScript solution that generates static CSS at build time. You write your styles in TypeScript files, get full type safety and autocompletion, and the output is plain CSS with zero runtime cost. No JavaScript ships to the browser for styling. No CSS-in-JS runtime. Just static stylesheets.

For Astro projects, this means you get the developer experience of CSS-in-JS (variables, theming, type-safe tokens) with the performance of traditional CSS. It works through a Vite plugin, which Astro uses as its build tool. Astro publishes an official vanilla-extract integration page, so this is a first-party-blessed pairing rather than a community hack.

Prerequisites

  • Node.js 22.12.0 or higher. Astro 6 requires node >=22.12.0 (Node 18 and Node 20 support was dropped because both reached or are nearing end of life). This is the single most common mismatch with older tutorials that still say Node 18.
  • An Astro project (npm create astro@latest). Astro 6 ships on Vite 7, and the vanilla-extract Vite plugin lists Vite ^5 || ^6 || ^7 || ^8 as a peer, so the pairing is compatible out of the box.
  • TypeScript configured (Astro includes this by default).

Verified-current versions as of 2026-05-29: Astro 6.4.2, @vanilla-extract/css 1.20.1, @vanilla-extract/vite-plugin 5.2.2, @vanilla-extract/sprinkles 1.6.5, @vanilla-extract/recipes 0.5.7.

Installation

The official vanilla-extract docs install the runtime as a normal dependency and the Vite plugin as a dev dependency, since the plugin only runs at build time:

npm install @vanilla-extract/css@1.20.1
npm install --save-dev @vanilla-extract/vite-plugin@5.2.2

For additional features:

# Sprinkles (utility-class generator)
npm install @vanilla-extract/sprinkles@1.6.5

# Recipes (variant API)
npm install @vanilla-extract/recipes@0.5.7

Configuration

Add the Vanilla Extract Vite plugin to your astro.config.mjs. This is the exact pattern shown in the official vanilla-extract Astro integration guide:

import { defineConfig } from "astro/config";
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";

export default defineConfig({
  vite: {
    plugins: [vanillaExtractPlugin()],
  },
});

That is the entire setup. No additional configuration is needed, and you do not need a separate Astro integration package. The import name is vanillaExtractPlugin and it goes inside Astro's vite.plugins array, which is Astro's documented escape hatch for passing Vite plugins straight through to the underlying bundler.

A Note on Astro Rendering Modes

Vanilla Extract compiles to static CSS at build time, so it works the same regardless of how a given route renders. Two facts about current Astro are worth pinning because older guides get them wrong:

  • Astro is static by default. Your whole site is prerendered to HTML unless you opt a route out. There is no extra config required to keep styles static.
  • output: 'hybrid' no longer exists. Astro 5 merged hybrid into static, and static now lets individual pages opt out of prerendering with export const prerender = false. If a route needs on-demand (SSR) rendering you must install a server adapter with astro add <adapter> (Node, Vercel, Netlify, Cloudflare). None of this changes how Vanilla Extract is wired in, because the CSS is generated at build time either way.

Creating Styles

Vanilla Extract styles live in .css.ts files. This naming convention tells the plugin to process them:

// src/styles/card.css.ts
import { style } from "@vanilla-extract/css";

export const card = style({
  backgroundColor: "white",
  borderRadius: "12px",
  padding: "24px",
  boxShadow: "0 1px 3px rgba(0, 0, 0, 0.1)",
  transition: "box-shadow 0.2s ease",
  ":hover": {
    boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
  },
});

export const cardTitle = style({
  fontSize: "1.25rem",
  fontWeight: 700,
  color: "#111827",
  marginBottom: "8px",
});

export const cardDescription = style({
  fontSize: "1rem",
  color: "#6B7280",
  lineHeight: 1.6,
});

Use them in your Astro components:

---
import { card, cardTitle, cardDescription } from "../styles/card.css";

export interface Props {
  title: string;
  description: string;
}

const { title, description } = Astro.props;
---

<div class={card}>
  <h3 class={cardTitle}>{title}</h3>
  <p class={cardDescription}>{description}</p>
</div>

The classes are hashed automatically, so you never have naming collisions. card becomes something like card_abc123 in the output.

Theme Tokens

Define a consistent design system with theme tokens:

// src/styles/theme.css.ts
import { createTheme, createThemeContract } from "@vanilla-extract/css";

export const vars = createThemeContract({
  color: {
    primary: null,
    secondary: null,
    text: null,
    textMuted: null,
    background: null,
    surface: null,
    border: null,
  },
  font: {
    body: null,
    heading: null,
    mono: null,
  },
  space: {
    xs: null,
    sm: null,
    md: null,
    lg: null,
    xl: null,
  },
  radius: {
    sm: null,
    md: null,
    lg: null,
    full: null,
  },
});

export const lightTheme = createTheme(vars, {
  color: {
    primary: "#0ea5e9",
    secondary: "#8b5cf6",
    text: "#111827",
    textMuted: "#6b7280",
    background: "#ffffff",
    surface: "#f9fafb",
    border: "#e5e7eb",
  },
  font: {
    body: "Inter, sans-serif",
    heading: "Inter, sans-serif",
    mono: "JetBrains Mono, monospace",
  },
  space: {
    xs: "4px",
    sm: "8px",
    md: "16px",
    lg: "32px",
    xl: "64px",
  },
  radius: {
    sm: "4px",
    md: "8px",
    lg: "16px",
    full: "9999px",
  },
});

export const darkTheme = createTheme(vars, {
  color: {
    primary: "#38bdf8",
    secondary: "#a78bfa",
    text: "#f9fafb",
    textMuted: "#9ca3af",
    background: "#111827",
    surface: "#1f2937",
    border: "#374151",
  },
  font: {
    body: "Inter, sans-serif",
    heading: "Inter, sans-serif",
    mono: "JetBrains Mono, monospace",
  },
  space: {
    xs: "4px",
    sm: "8px",
    md: "16px",
    lg: "32px",
    xl: "64px",
  },
  radius: {
    sm: "4px",
    md: "8px",
    lg: "16px",
    full: "9999px",
  },
});

Use the theme tokens in your styles:

// src/styles/button.css.ts
import { style } from "@vanilla-extract/css";
import { vars } from "./theme.css";

export const button = style({
  backgroundColor: vars.color.primary,
  color: "white",
  padding: vars.space.sm + " " + vars.space.md,
  borderRadius: vars.radius.md,
  fontFamily: vars.font.body,
  fontWeight: 600,
  border: "none",
  cursor: "pointer",
  transition: "opacity 0.2s ease",
  ":hover": {
    opacity: 0.9,
  },
});

Apply the theme to your layout:

---
import { lightTheme, darkTheme } from "../styles/theme.css";
---

<html class={lightTheme}>
  <body>
    <slot />
  </body>
</html>

Recipes for Variants

The Recipes API lets you define component variants in a type-safe way:

// src/styles/badge.css.ts
import { recipe } from "@vanilla-extract/recipes";
import { vars } from "./theme.css";

export const badge = recipe({
  base: {
    display: "inline-flex",
    alignItems: "center",
    borderRadius: vars.radius.full,
    fontSize: "0.75rem",
    fontWeight: 600,
    textTransform: "uppercase",
    letterSpacing: "0.05em",
  },
  variants: {
    color: {
      primary: { backgroundColor: "#dbeafe", color: "#1d4ed8" },
      success: { backgroundColor: "#dcfce7", color: "#166534" },
      warning: { backgroundColor: "#fef3c7", color: "#92400e" },
      error: { backgroundColor: "#fce4ec", color: "#b71c1c" },
    },
    size: {
      sm: { padding: "2px 6px", fontSize: "0.625rem" },
      md: { padding: "4px 8px", fontSize: "0.75rem" },
      lg: { padding: "6px 12px", fontSize: "0.875rem" },
    },
  },
  defaultVariants: {
    color: "primary",
    size: "md",
  },
});

Use it with full type safety:

---
import { badge } from "../styles/badge.css";
---

<span class={badge({ color: "success", size: "sm" })}>Published</span>
<span class={badge({ color: "warning" })}>Draft</span>
<span class={badge({ color: "error", size: "lg" })}>Deleted</span>

Sprinkles for Utility Classes

Sprinkles generates a set of utility classes from your theme tokens, similar to Tailwind but fully type-safe:

// src/styles/sprinkles.css.ts
import { defineProperties, createSprinkles } from "@vanilla-extract/sprinkles";
import { vars } from "./theme.css";

const responsiveProperties = defineProperties({
  conditions: {
    mobile: {},
    tablet: { "@media": "screen and (min-width: 768px)" },
    desktop: { "@media": "screen and (min-width: 1024px)" },
  },
  defaultCondition: "mobile",
  properties: {
    display: ["none", "flex", "block", "grid"],
    flexDirection: ["row", "column"],
    gap: vars.space,
    padding: vars.space,
    fontSize: {
      sm: "0.875rem",
      base: "1rem",
      lg: "1.125rem",
      xl: "1.25rem",
    },
  },
});

const colorProperties = defineProperties({
  properties: {
    color: vars.color,
    backgroundColor: vars.color,
  },
});

export const sprinkles = createSprinkles(responsiveProperties, colorProperties);

Production Tips

  1. Keep styles colocated. Put .css.ts files next to the components that use them. This makes it easy to find and update styles when refactoring.

  2. Use theme contracts for consistency. Define your design tokens in a theme contract. This enforces that all theme variations (light, dark, branded) provide every required value.

  3. Prefer recipes over conditionals. Instead of switching between class names with JavaScript, use the Recipes API to define variants declaratively. The output is static CSS, not runtime logic.

  • Watch file sizes. Vanilla Extract generates one class per style call. For large design systems, this can produce many small classes. Monitor your CSS bundle size with tools like source-map-explorer.

  • Use globalStyle sparingly. Vanilla Extract provides globalStyle() for targeting elements outside your component tree (like reset styles). Keep it to your global layout file to avoid specificity issues.

  • Alternatives to Consider

    • Tailwind CSS if you prefer utility-first classes without needing to write TypeScript files for styles.
    • CSS Modules if you want scoped styles with plain CSS syntax and no additional build tooling.
    • Panda CSS if you want a similar type-safe approach with built-in utility classes and a more runtime-like API.

    Common Errors and Fixes

    "Cannot find module" or build error on node_modules, plus a Node version warning. Astro 6 requires node >=22.12.0 (and npm >=9.6.5). If you are on Node 18 or 20, the install or build will warn or fail. Upgrade Node first, then reinstall. This is the number one reason an old vanilla-extract tutorial stops working.

    TypeScript path aliases fail to resolve inside .css.ts files. Importing a .css.ts token file through a custom tsconfig alias (for example @tokens/colors) has historically thrown a FailedToLoadModuleSSR error like Could not import @tokens/colors because Astro's vite-node environment did not resolve the alias for the plugin. This was reported against the plugin in Astro and was fixed by an upstream patch, so make sure you are on a current @vanilla-extract/vite-plugin (5.2.2 at time of writing). If you still hit it, switch the affected import to a relative path (../styles/theme.css) as a workaround.

    Plugin imported as a default export. vanillaExtractPlugin is a named export. import vanillaExtractPlugin from "@vanilla-extract/vite-plugin" will be undefined. Use the named form: import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin", exactly as the official docs show.

    Styles defined in the wrong file extension do nothing. The Vite plugin only processes files matching *.css.ts (and *.css.js). Calling style() from a plain .ts file produces no CSS and no error. Rename the file to end in .css.ts.

    Putting the plugin in the wrong place. The plugin belongs in Astro's vite.plugins array inside defineConfig, not in a top-level plugins key. Astro forwards vite.plugins to the underlying Vite 7 bundler; a top-level plugins key is for Astro integrations, which this package is not.

    Vite peer version mismatch on an older Astro. The plugin peers Vite ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0. Astro 6 already ships Vite 7, so you are inside the supported range. If you pinned a custom older Vite, align it with what your Astro version ships.

    Official Docs and Examples

    Wrapping Up

    Vanilla Extract brings type safety to CSS without any runtime overhead. For Astro projects, this means your styles are validated at build time, your theme tokens are enforced by TypeScript, and the output is static CSS that performs like hand-written stylesheets. The learning curve is minimal if you already know CSS-in-JS patterns, and the Recipes and Sprinkles APIs give you the building blocks for a complete design system. If you value type safety and zero-runtime CSS, Vanilla Extract is the tool to reach for.

    Sources

    Checked on 2026-05-29.