Native CSS
This guide shows how to use webpack's native CSS handling with experiments.css, and how to migrate an existing setup off css-loader, style-loader, and mini-css-extract-plugin.
Getting Started
On webpack 5.109.0 or later there is nothing to install and nothing to configure — import a .css file and webpack handles it:
import "./styles.css";experiments.css defaults to 'auto', which turns the built-in support on as long as no module.rules entry already claims .css files. Set it to true yourself only when you add such a rule — for example to run postcss-loader on .css files:
webpack.config.js
export default {
experiments: {
css: true,
},
};Either way, webpack understands .css files as first-class modules: it parses them itself instead of handing them to a loader.
What's built-in
"Built-in" means webpack does the work itself, with no loader in the chain — not that it covers everything the CSS loaders do. The scope is exactly this:
| Built in | Still needs a loader or plugin |
|---|---|
Parsing .css; resolving @import and url() / image-set() / src() / image() | Preprocessors — Sass, Less, Stylus and PostCSS always keep their loaders |
CSS Modules: composes, @value, :export, :local() / :global() | css-loader's importLoaders, localIdentRegExp, getJSON, and the url / import filter callbacks |
Extracting a .css file, or injecting a <style> tag at runtime | style-loader's insert, attributes, styleTagTransform, and its lazy / singleton injection modes |
| Content hashes, minification and browserslist vendor prefixes | Anything a PostCSS plugin does beyond @custom-media / @custom-selector |
@custom-media and @custom-selector | - |
| Hot Module Replacement for stylesheets | - |
So a project whose CSS setup is css-loader + style-loader (or mini-css-extract-plugin) with default options needs no CSS loader at all. A project that reaches for the options in the right-hand column keeps that loader for the files that need it — the two can coexist, rule by rule.
Importing CSS
After enabling the experiment, import .css files directly from JavaScript:
src/index.js
import "./styles.css";
const element = document.createElement("h1");
element.textContent = "Hello native CSS";
document.body.appendChild(element);src/styles.css
h1 {
color: #1f6feb;
}Webpack processes the CSS and includes it in the build output.
CSS module types
Native CSS introduces four Rule.type values. Knowing which one applies is the key to migrating, because each maps to a different css-loader modules.mode:
| Type | Scoping | css-loader equivalent |
|---|---|---|
css | Global, no CSS Modules parsing | modules: false |
css/global | Global selectors, but :local() is honored | modules.mode: 'global' |
css/module | Local by default, :global() escapes to global | modules.mode: 'local' |
css/auto | Picks css/module for *.module.css / *.modules.css, otherwise plain css (global, no CSS Modules parsing) | modules.auto: true |
The default rule webpack adds for /\.css$/i is css/auto, so *.module.css files become CSS Modules and everything else stays global — matching the most common css-loader configuration out of the box.
How css/auto handles each file
css/auto decides per file, by name: a resource path matching /\.modules?\.[^.]+$/i is parsed as a CSS Module (css/module), anything else as plain global CSS (css). The test looks at the file name only, with any extension — so it applies just as well to the output of a preprocessor rule as to .css files.
Given this project, with no CSS configuration at all apart from one Sass rule:
webpack.config.js
export default {
module: {
rules: [
{
test: /\.s[ac]ss$/i,
use: ["sass-loader"],
type: "css/auto",
},
],
},
};src/index.js
import "./reset.css";
import "./layout.scss";
import * as button from "./button.module.scss";
import * as card from "./card.module.css";each file is handled like this (class names from a project whose package.json name is my-app):
| File | Parsed as | What the import gives JavaScript | Emitted selector, development | Emitted selector, production |
|---|---|---|---|---|
reset.css | css — global | nothing (side-effect import) | .title — unchanged | .title |
layout.scss | css — global | nothing (side-effect import) | .layout .sidebar — unchanged | .layout .sidebar |
button.module.scss | css/module — scoped | button.button | .my-app-src_button_module_scss-button | .pRFShR |
card.module.css | css/module — scoped | card.card, card["card-title"] | .my-app-src_card_module_css-card | .-Nx8GA |
All four end up in the same extracted main.css, in import order. Inside a CSS Module, :global(.theme-dark) .card keeps .theme-dark as written and scopes only .card, and composes makes the exported value a space-separated list of class names.
To treat a file differently from what its name says, give its rule an explicit type instead: css/module makes every matched file scoped, css/global keeps selectors global but honors :local(), and css disables CSS Modules parsing entirely.
Naming the scoped classes
Scoped class names come from the localIdentName generator option. It sits directly on the generator options for the module type — module.generator["css/auto"] for every file the default rule handles, or a rule's own generator for a subset:
webpack.config.js
export default {
module: {
generator: {
"css/auto": {
localIdentName: "[local]_[hash:base64url:5]",
},
},
},
};localIdentName | .card in src/card.module.css becomes | Notes |
|---|---|---|
"[uniqueName]-[id]-[local]" | .my-app-src_card_module_css-card | Default in development. [id] is the module id, so it can change between builds. |
"[fullhash]" | .-Nx8GA | Default in production. The local ident hash, not the compilation hash — see below. |
"[local]_[hash:base64url:5]" | .card_-Nx8G | Readable and short. [hash] here is the same local ident hash, cut to 5 characters. |
"[uniqueName]-[local]-[fullhash:6]" | .my-app-card--Nx8GA | Prefixes classes when several webpack builds share one page. |
The local ident hash is computed from localIdentHashSalt, output.uniqueName, the file path relative to context and the local name, so it is stable across builds and across web / node targets.
CSS Modules
With css/auto, name a file *.module.css (or *.modules.css) to opt it into CSS Modules:
src/button.module.css
.button {
background: #0d6efd;
color: white;
border: 0;
border-radius: 4px;
padding: 8px 12px;
}src/index.js
import * as styles from "./button.module.css";
const button = document.createElement("button");
button.className = styles.button;
button.textContent = "Click me";
document.body.appendChild(button);You can customize CSS Modules behavior with parser and generator options — see All options with examples below:
webpack.config.js
export default {
experiments: {
css: true,
},
module: {
parser: {
"css/auto": {
namedExports: true,
},
},
generator: {
"css/auto": {
exportsConvention: "camel-case-only",
localIdentName: "[uniqueName]-[id]-[local]",
},
},
},
};Supported CSS Modules features
Native CSS Modules understand the same authoring features as css-loader, so most stylesheets migrate unchanged:
composes— compose one local class from another (includingcomposes: foo from "./other.module.css"); the export resolves to the space-separated list of class names.@value— declare and import reusable values (@value primary: #1f6feb;,@value primary from "./vars.module.css").:export— expose arbitrary key/value pairs to JavaScript.:local()/:global()— switch scoping inline within any module type.
/* button.module.css */
@value brand: #1f6feb;
.base {
padding: 8px 12px;
}
.primary {
composes: base;
background: brand;
}
:export {
brandColor: brand;
}Output modes (exportType)
A single CSS module can be emitted in four ways. The exportType parser option selects which one, and each replaces a different piece of the classic toolchain:
exportType | Behavior | Replaces |
|---|---|---|
"link" (default) | Extracts a .css file, loaded via <link> | mini-css-extract-plugin |
"style" | Injects a <style> element from the runtime | style-loader |
"text" | Exports the CSS as a string | css-loader exportType: 'string' |
"css-style-sheet" | Exports a constructable CSSStyleSheet | css-loader exportType: 'css-style-sheet' |
Set it globally per module type:
export default {
experiments: { css: true },
module: {
parser: {
"css/auto": {
exportType: "style",
},
},
},
};or per rule for a subset of files:
export default {
experiments: { css: true },
module: {
rules: [
{
test: /\.css$/i,
type: "css/auto",
parser: { exportType: "style" },
},
],
},
};CSS and chunks (splitChunks)
With the default exportType: "link", CSS follows the chunk graph exactly like JavaScript does: every chunk that contains CSS modules gets its own .css file, named by output.cssFilename for initial chunks and output.cssChunkFilename for the rest. No plugin decides where styles go — optimization.splitChunks does, the same way it does for scripts.
Take two entries that both import shared.css and card.module.css, plus one stylesheet of their own each, and a lazily imported lazy.css:
// home.js
import "./shared.css";
import "./card.module.css";
import "./home.css";
import("./lazy.css");optimization.splitChunks | Emitted CSS | Result |
|---|---|---|
default (chunks: "async") | home.css, about.css, [id].css for lazy.css | Shared styles are duplicated into each entry's stylesheet; the dynamic import gets its own file. |
{ chunks: "all" } | home.css, about.css, plus a shared [id].css | Shared modules move to a common chunk once they pass minSize; each page loads two stylesheets. |
a cache group with type: /^css/, chunks: "all", enforce: true | one styles.css | Every stylesheet in one file, loaded by every entry. |
The loading side needs no configuration: an entry's initial CSS chunks are listed in the entrypoint (and injected by native HTML when it builds the page), and the runtime inserts a <link> for an async chunk's CSS before it resolves the import().
Splitting by module type
A cache group's type option selects modules by their module type, which for native CSS is the type the matching rule assigned — css/auto for the default rule, but css/module or css/global for a rule that sets one explicitly. Use a regular expression so the group catches all of them:
webpack.config.js
export default {
optimization: {
splitChunks: {
cacheGroups: {
styles: {
// Matches css, css/auto, css/module and css/global.
type: /^css/,
name: "styles",
chunks: "all",
enforce: true,
},
},
},
},
};To keep vendor styles in their own long-cached file instead, combine type with test:
export default {
optimization: {
splitChunks: {
cacheGroups: {
vendorStyles: {
type: /^css/,
test: /[\\/]node_modules[\\/]/,
name: "vendor-styles",
chunks: "all",
enforce: true,
},
},
},
},
};Preprocessors, PostCSS and Tailwind
Native CSS replaces css-loader, style-loader and mini-css-extract-plugin — not the tools that produce CSS. Sass, Less, Stylus and PostCSS keep their loaders, and the rule's type hands what they output straight to webpack's CSS pipeline, which then resolves @import and url(), applies CSS Modules, extracts, hashes and minifies it. The loaders that used to sit between them and the page drop out of the chain:
{
test: /\.s[ac]ss$/i,
- use: ["style-loader", "css-loader", "postcss-loader", "sass-loader"],
+ use: ["postcss-loader", "sass-loader"],
+ type: "css/auto",
}| Tool | Rule | experiments.css: true needed? |
|---|---|---|
| Sass / SCSS | test: /\.s[ac]ss$/i, use: ["sass-loader"], type: "css/auto" | No |
| Less | test: /\.less$/i, use: ["less-loader"], type: "css/auto" | No |
| Stylus | test: /\.styl$/i, use: ["stylus-loader"], type: "css/auto" | No |
| PostCSS, Tailwind | test: /\.css$/i, use: ["postcss-loader"], type: "css/auto" | Yes — a rule for .css files switches the 'auto' default off |
The last column follows from how 'auto' works: any rule that matches .css files with a loader or a type counts as "this project handles CSS itself", and the build then fails with No parser registered for css/auto unless you set experiments.css: true. Rules for .scss, .less or .styl don't match .css files, so they leave the default alone.
Sass
webpack.config.js
export default {
module: {
rules: [
{
test: /\.s[ac]ss$/i,
use: ["sass-loader"],
type: "css/auto",
},
],
},
};One rule covers both kinds of file: css/auto checks the file name, so button.module.scss becomes a CSS Module and layout.scss stays global. Two Sass specifics to know about:
-
url()in partials. Sass inlines a partial without rewriting its URLs, sourl("./img/icon.png")insidepartials/_icon.scssis resolved relative to the.scssfile that was imported from JavaScript — just as it was withcss-loader. Write such URLs relative to the entry file, use aresolve.alias, or addresolve-url-loader, which works in the native chain unchanged:export default { module: { rules: [ { test: /\.s[ac]ss$/i, use: [ "resolve-url-loader", // resolve-url-loader needs the source map to know which file a URL came from. { loader: "sass-loader", options: { sourceMap: true } }, ], type: "css/auto", }, ], }, }; -
Source maps. The maps a loader returns are passed on, so with a
devtoolset, the emitted.css.mappoints at the original.scsspartials.
Less and Stylus work the same way with less-loader and stylus-loader.
PostCSS and Tailwind CSS
PostCSS runs on plain .css files, so its rule needs experiments.css: true. For Tailwind CSS v4:
npm install --save-dev tailwindcss @tailwindcss/postcss postcss postcss-loaderpostcss.config.mjs
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};src/styles.css
@import "tailwindcss";webpack.config.js
export default {
experiments: { css: true },
module: {
rules: [
{
test: /\.css$/i,
use: ["postcss-loader"],
type: "css/auto",
},
],
},
};postcss-loader runs Tailwind, which expands @import "tailwindcss" into the utilities your sources use, and webpack bundles the result like any other stylesheet — *.module.css files processed by the same rule are still CSS Modules. Tailwind CSS v3 needs the same webpack configuration, with tailwindcss (and its @tailwind directives) in place of @tailwindcss/postcss.
To run PostCSS over Sass output too, list it before the preprocessor — loaders run right to left, so Sass compiles first: use: ["postcss-loader", "sass-loader"].
Custom media and custom selectors
Native CSS resolves @custom-media and @custom-selector at build time, so the two rules people most often reach for a PostCSS plugin for need no plugin:
src/styles.css
@custom-media --narrow (width <= 40rem);
@custom-selector :--heading h1, h2, h3;
@media (--narrow) {
:--heading {
font-size: 1.25rem;
}
}Resolution is file-local — a definition applies only to the file that declares it, not to the files it @imports nor to the whole project. Both are on by default and can be switched off individually with module.parser.css.customMedia and module.parser.css.customSelectors, which is what you want if a PostCSS plugin in your chain already handles them.
Minification
CSS assets are minified by the built-in minimizer whenever optimization.minimize is on — which it is by default in mode: 'production'. There is nothing to install and nothing to wire up:
webpack.config.js
export default {
mode: "production",
experiments: { css: true },
};That replaces css-minimizer-webpack-plugin and the cssnano chain behind it. Every transform that preserves what the stylesheet means is on by default — shortening colors, numbers, values and selectors, merging longhands and rules, dropping dead rules, folding calc() and other math over constants, normalizing quotes, and writing media queries in their range spelling. Two are off because they change text a script can read back (rewriteCustomProperties) or rarely pay for themselves once compressed (convertLengthUnits).
Configure them through the object form of optimization.minimize:
export default {
mode: "production",
experiments: { css: true },
optimization: {
minimize: {
css: {
comments: false,
// `getComputedStyle().getPropertyValue()` hands custom property text
// back as authored — opt in only if nothing reads it.
rewriteCustomProperties: true,
},
},
},
};Disable CSS minification alone with minimize: { css: false }, leaving JavaScript minification in place.
Vendor prefixes
The minimizer maintains vendor prefixes for your browserslist target: it adds the -webkit- / -moz- / -ms- spelling of a property, at-rule or pseudo-selector a selected browser still needs, and drops the ones none of them do. For most projects that is autoprefixer — and so postcss-loader — off the dependency list:
.browserslistrc
> 0.5%
last 2 versions
not deadwebpack.config.js
export default {
mode: "production",
target: "browserslist",
experiments: { css: true },
};Resource hints and font preloading
Assets referenced from CSS can be given <link rel="preload"> / prefetch hints without a plugin. module.parser.css.fontPreload seeds one preload for each @font-face reachable from an HTML entry's initial CSS — the primary src URL only, since preloading every format would download the font twice:
webpack.config.js
export default {
experiments: { css: true, html: true },
output: {
// A preload must match the font's CORS fetch.
crossOriginLoading: "anonymous",
},
module: {
parser: {
css: {
fontPreload: true,
},
},
},
};module.parser.css.urlHints gives per-asset rules for anything else reached through url(...), and output.resourceHints sets them project-wide:
export default {
experiments: { css: true, html: true },
module: {
parser: {
css: {
urlHints: [
{ test: /\.woff2$/, preload: true, as: "font" },
{ test: /hero\.avif$/, preload: true, as: "image" },
],
},
},
},
};CSS referenced from HTML
With experiments.html enabled, a <link rel="stylesheet"> in an HTML page becomes a CSS chunk entry and inline <style> bodies (and style="" attributes) run through this same CSS pipeline — @import and url() inside them resolve relative to the HTML file, and the minimizer options above apply to them too:
webpack.config.js
export default {
entry: "./src/index.html",
experiments: { css: true, html: true },
};See the Native HTML guide for the whole HTML story.
Migration Guide
Automated migration (codemod)
Most of the migration can be done for you by the @webpack/css-plugins-to-native-css codemod:
npx codemod @webpack/css-plugins-to-native-cssIt requires webpack 5.109.0 or later, since it relies on the experiments.css: "auto" default introduced in that release.
At a glance
| Legacy setup | Native equivalent |
|---|---|
mini-css-extract-plugin (MiniCssExtractPlugin.loader) | built-in extraction (default exportType: "link") |
MiniCssExtractPlugin filename / chunkFilename | output.cssFilename / output.cssChunkFilename |
style-loader | exportType: "style" |
css-loader | built-in CSS parsing (no loader needed) |
css-loader url / import | module.parser.css.url / import (both default true) |
css-loader modules (.module.css auto-detect) | css/auto module type |
css-loader modules.mode | css/module / css/global type + pure |
css-loader modules.localIdentName | generator localIdentName |
css-loader modules.exportLocalsConvention | generator exportsConvention |
css-loader modules.namedExport | module.parser.css.namedExports (default true) |
css-loader modules.exportOnlyLocals | generator exportsOnly |
css-loader esModule | generator esModule (default true) |
css-loader exportType: 'string' / 'css-style-sheet' | exportType: "text" / "css-style-sheet" |
css-minimizer-webpack-plugin / cssnano | optimization.minimize — on by default in production |
postcss-loader + autoprefixer | optimization.minimize.css.vendorPrefixes — on by default for a browserslist target |
postcss-custom-media / postcss-custom-selectors | built-in @custom-media / @custom-selector |
preload-webpack-plugin (fonts) | module.parser.css.fontPreload |
Migrate one loader at a time — the sections below go in the order that keeps the build green at each step.
1. Start from a classic setup
webpack.config.js
import MiniCssExtractPlugin from "mini-css-extract-plugin";
export default {
module: {
rules: [
{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, "css-loader"],
},
],
},
plugins: [new MiniCssExtractPlugin()],
};2. Enable native CSS
webpack.config.js
export default {
experiments: {
css: true,
},
};The built-in /\.css$/i → css/auto rule now handles .css imports. Remove your custom rule and plugin once the following sections confirm each option has an equivalent.
3. Replace mini-css-extract-plugin
Native CSS extracts stylesheets and adds content hashes to them by default (exportType: "link"), so the plugin and its loader are no longer needed:
webpack.config.js
-import MiniCssExtractPlugin from "mini-css-extract-plugin";
-
export default {
+ experiments: {
+ css: true,
+ },
- module: {
- rules: [
- {
- test: /\.css$/i,
- use: [MiniCssExtractPlugin.loader, "css-loader"],
- },
- ],
- },
- plugins: [new MiniCssExtractPlugin()],
};Map the remaining plugin options:
mini-css-extract-plugin | Native equivalent |
|---|---|
filename | output.cssFilename |
chunkFilename | output.cssChunkFilename |
loader publicPath | output.publicPath |
loader esModule | generator esModule (default true) |
ignoreOrder | n/a — the order-conflict warning cannot be silenced per option; use ignoreWarnings, or CssModulesPlugin.getCompilationHooks(compilation).orderModules to define the order yourself |
webpack.config.js
export default {
experiments: { css: true },
output: {
cssFilename: "[name].[contenthash].css",
cssChunkFilename: "[id].[contenthash].css",
},
};4. Replace css-loader
Most css-loader options have a native counterpart under module.parser.css and module.generator.css. The common defaults (url, import, namedExports all on) already match a typical css-loader config, so many projects need no parser config at all.
css-loader option | Native equivalent |
|---|---|
url | module.parser.css.url — default true |
import | module.parser.css.import — default true |
importLoaders | n/a — loaders in the chain apply to @imported files automatically |
sourceMap | controlled by devtool (supports a per-type css entry) |
esModule | module.generator.css.esModule — default true |
exportType: 'string' | parser exportType: "text" |
exportType: 'css-style-sheet' | parser exportType: "css-style-sheet" |
modules (auto-detect) | css/auto module type (built-in) |
modules.mode: 'local' | css/module type |
modules.mode: 'global' | css/global type |
modules.mode: 'pure' | parser pure: true |
modules.localIdentName | generator localIdentName |
modules.exportLocalsConvention | generator exportsConvention |
modules.namedExport | parser namedExports — default true |
modules.exportOnlyLocals | generator exportsOnly |
modules.localIdentHashSalt | generator localIdentHashSalt |
modules.localIdentHashFunction | generator localIdentHashFunction |
For example, this css-loader CSS Modules config:
export default {
module: {
rules: [
{
test: /\.module\.css$/i,
use: [
{
loader: "css-loader",
options: {
modules: {
localIdentName: "[local]-[hash:base64:6]",
exportLocalsConvention: "camel-case-only",
namedExport: true,
},
},
},
],
},
],
},
};becomes:
webpack.config.js
export default {
experiments: { css: true },
module: {
parser: {
"css/auto": {
namedExports: true,
},
},
generator: {
"css/auto": {
localIdentName: "[local]-[hash:base64:6]",
exportsConvention: "camel-case-only",
},
},
},
};A few css-loader options work differently:
getLocalIdent— instead of a custom function, native CSS drives naming through thelocalIdentNametemplate, which also accepts a function.getJSON— the class-name mapping is exported by the CSS module itself and readable from the compilation's module graph, so a small plugin can serialize it to JSON when a framework needs the file on disk. For server-side rendering, you usually don't need it at all — see Server-side rendering.localIdentRegExpand filter-styleurl/importcallbacks have no native equivalent; keepcss-loaderfor the affected files, or exclude specific requests withIgnorePlugin.
5. Replace style-loader
If you used style-loader to inject styles at runtime instead of extracting a file, set exportType: "style":
webpack.config.js
export default {
experiments: { css: true },
module: {
parser: {
"css/auto": {
exportType: "style",
},
},
},
};This injects a <style> element from the webpack runtime, covering the default style-loader (injectType: "styleTag") use case. Scope it to a single rule if only some files should be injected while the rest are extracted:
export default {
experiments: { css: true },
module: {
rules: [
{
test: /\.inline\.css$/i,
type: "css/auto",
parser: { exportType: "style" },
},
],
},
};Notes on style-loader options: injectType: "linkTag" corresponds to the default exportType: "link" (extraction); attributes, insert, and styleTagTransform have no native equivalent — keep style-loader if you rely on them.
6. Keep using preprocessors (Sass, Less, PostCSS)
Native CSS replaces the CSS loaders, not preprocessor loaders. Remove style-loader / MiniCssExtractPlugin.loader and css-loader from the chain, keep the rest, and set the rule's type to css/auto so webpack treats the loader's output as CSS:
webpack.config.js
-import MiniCssExtractPlugin from "mini-css-extract-plugin";
-
export default {
module: {
rules: [
{
test: /\.s[ac]ss$/i,
- use: [MiniCssExtractPlugin.loader, "css-loader", "postcss-loader", "sass-loader"],
+ use: ["postcss-loader", "sass-loader"],
+ type: "css/auto",
},
],
},
- plugins: [new MiniCssExtractPlugin()],
};sass-loader compiles to CSS, postcss-loader post-processes it, and native CSS handles extraction, url(), and CSS Modules from there. The same pattern works for less-loader, stylus-loader, and friends. If you split *.module.scss and other Sass files into two rules for css-loader's modules option, a single css/auto rule now covers both. Preprocessors, PostCSS and Tailwind covers when experiments.css: true is needed, Tailwind CSS, and url() in Sass partials.
For an inline <style lang="scss"> in an HTML page, register the loader by mimetype instead — see Preprocessors in inline <style> and <script>.
7. Server-side rendering (node + web)
For SSR you typically build twice — a web bundle for the browser and a node bundle for the server — and the CSS Modules class names must match so the server-rendered markup hydrates cleanly on the client. This is what css-loader's getJSON was often used to round-trip; with native CSS you avoid the round-trip entirely by making localIdentName deterministic across targets.
Class names are deterministic across targets as long as both builds share the localIdentName template, resolve every placeholder in it to the same value (for example, [id] is the module id, which can differ between separate builds), and share the local ident hash inputs described below. A readable path-based template works on every target:
webpack.config.js
const common = {
experiments: { css: true },
module: {
rules: [
{
test: /\.module\.css$/i,
type: "css/module",
generator: {
// `[file]__[local]` is stable across targets — no getJSON sync needed.
localIdentName: "[file]__[local]",
},
},
],
},
};
export default [
{ ...common, name: "web", target: "web" },
{ ...common, name: "node", target: "node" },
];On a node target the CSS generator defaults to exportsOnly: true, so the server build exports only the class-name mapping and emits no stylesheet — exactly what an SSR renderer needs. The browser build still extracts the real CSS. If you prefer a single config, target: ["web", "node"] builds a universal bundle that runs in both environments.
The production default localIdentName: "[fullhash]" (and any [hash] template) is SSR-safe too. Despite its name, [fullhash] in localIdentName is not the compilation hash: it is the local ident hash, computed from:
localIdentHashSalt(defaults tooutput.hashSalt),output.uniqueName— included even when[uniqueName]is not in the template,- the resource path relative to
context, - the local class name.
So you get short class names (6 characters by default) that match between the web and node builds, and the same local name in two files still gets different class names.
8. Drop the CSS minimizer and autoprefixer
optimization.minimize covers both in production, so the minimizer plugin and — for most projects — the PostCSS chain that only ran autoprefixer can go:
webpack.config.js
-import CssMinimizerPlugin from "css-minimizer-webpack-plugin";
-
export default {
mode: "production",
+ target: "browserslist",
+ experiments: { css: true },
- optimization: {
- minimizer: ["...", new CssMinimizerPlugin()],
- },
};See Minification for what the built-in minimizer does and how to tune it.
9. Keep imports unchanged and validate
Your JS imports stay the same:
import "./styles.css";
import * as styles from "./button.module.css";Then check that:
- styles apply correctly in development,
- extracted
.cssfiles are emitted in production, - CSS Modules exports match your existing usage.
All options with examples
Configure options per module type under module.parser and module.generator. The keys are css, css/auto, css/global, and css/module; the examples below use css/auto since it backs the default rule.
Parser options
Boolean parser options default to true unless the table says otherwise.
| Option | Type | Default | Description |
|---|---|---|---|
import | boolean | true | Handle @import at-rules. |
url | boolean | true | Handle url() / image-set() / src() / image(). |
namedExports | boolean | true | Export CSS Modules locals as ES module named exports. |
exportType | "link" | "style" | "text" | "css-style-sheet" | "link" | How the CSS is emitted (see Output modes). |
pure | boolean | false | Strict pure mode — every selector must contain a local class/id. css/module and css/auto only. |
as | "stylesheet" | "block-contents" | "stylesheet" | Parse the source as a full stylesheet or as a block's contents. |
animation | boolean | true | Rename local @keyframes names. |
container | boolean | true | Rename local @container names. |
customIdents | boolean | true | Rename custom identifiers. |
dashedIdents | boolean | true | Rename dashed identifiers (custom properties). |
function | boolean | true | Rename local @function names. |
grid | boolean | true | Rename grid line/area identifiers. |
customMedia | boolean | true | Resolve @custom-media at build time. |
customSelectors | boolean | true | Resolve @custom-selector at build time. |
fontPreload | boolean | false | Preload each @font-face's primary src from an HTML entry. |
urlHints | UrlHintRule[] | — | Resource-hint rules for assets referenced via url(...). |
export default {
experiments: { css: true },
module: {
parser: {
"css/auto": {
import: true,
url: true,
namedExports: true,
exportType: "link",
pure: false,
// Only rename @keyframes; leave @container / grid identifiers untouched.
animation: true,
container: false,
grid: false,
},
},
},
};Generator options
| Option | Type | Default | Description |
|---|---|---|---|
localIdentName | string | function | "[uniqueName]-[id]-[local]" (dev) / "[fullhash]" (prod) | Template for generated local class names. |
exportsConvention | "as-is" | "camel-case" | "camel-case-only" | "dashes" | "dashes-only" | function | "as-is" | Naming convention for exported locals. |
exportsOnly | boolean | true on targets without a document (e.g. node), else false | Only export locals; skip emitting a stylesheet (SSR). |
esModule | boolean | true | Emit ES module syntax for the generated JS. |
localIdentHashFunction | string | output.hashFunction | Hash function for localIdentName hashes. |
localIdentHashDigest | string | "base64url" | Hash digest encoding for local idents. |
localIdentHashDigestLength | number | 6 | Hash digest length for local idents. |
localIdentHashSalt | string | output.hashSalt | Hash salt for local idents. |
export default {
experiments: { css: true },
module: {
generator: {
"css/auto": {
localIdentName: "[uniqueName]-[id]-[local]",
exportsConvention: "camel-case-only",
esModule: true,
exportsOnly: false,
localIdentHashDigest: "base64url",
localIdentHashDigestLength: 6,
},
},
},
};exportsConvention also accepts a function returning a string or string[] — returning an array exports the local under several aliases, matching css-loader's behavior.
Popular examples
CSS Modules with named exports
src/app.module.css
.primary {
color: #1f6feb;
}
.large-text {
font-size: 2rem;
}src/index.js
import { largeText, primary } from "./app.module.css";
document.body.classList.add(primary, largeText);webpack.config.js
export default {
experiments: { css: true },
module: {
generator: {
"css/auto": {
exportsConvention: "camel-case-only",
},
},
},
};Extract hashed CSS files for production
webpack.config.js
export default {
mode: "production",
experiments: { css: true },
output: {
cssFilename: "css/[name].[contenthash].css",
cssChunkFilename: "css/[id].[contenthash].css",
},
};Inject <style> tags at runtime (style-loader style)
webpack.config.js
export default {
experiments: { css: true },
module: {
parser: {
"css/auto": {
exportType: "style",
},
},
},
};Import a constructable stylesheet
src/index.js
import sheet from "./theme.css" with { type: "css" };
document.adoptedStyleSheets = [sheet];Webpack resolves the with { type: "css" } import assertion to exportType: "css-style-sheet" automatically, giving you a CSSStyleSheet instance.
Import CSS as a string
webpack.config.js
export default {
experiments: { css: true },
module: {
parser: {
"css/auto": {
exportType: "text",
},
},
},
};src/index.js
import css from "./styles.css";
const style = new CSSStyleSheet();
style.replaceSync(css);Global styles + scoped modules side by side
With the default css/auto rule, *.module.css is scoped and everything else is global — no extra config:
import "./reset.css"; // global
import * as card from "./card.module.css"; // scopedTailwind CSS (or any PostCSS chain)
Keep postcss-loader and give the rule a CSS module type so webpack handles what comes out of it — the full Tailwind setup is under PostCSS and Tailwind CSS:
webpack.config.js
export default {
experiments: { css: true },
module: {
rules: [
{
test: /\.css$/i,
use: ["postcss-loader"],
type: "css/auto",
},
],
},
};Sass with CSS Modules
webpack.config.js
export default {
module: {
rules: [
{
test: /\.s[ac]ss$/i,
use: ["sass-loader"],
type: "css/auto",
},
],
},
};card.module.scss is scoped and layout.scss is global. To scope every Sass file regardless of its name, use type: "css/module" instead.
Group every stylesheet into one file
By default CSS follows the chunk graph, so each entry gets its own stylesheet. A cache group keyed on the CSS module type collects them all into one instead — see CSS and chunks for the other layouts:
webpack.config.js
export default {
mode: "production",
entry: { home: "./src/home.js", about: "./src/about.js" },
experiments: { css: true },
output: {
cssFilename: "css/[name].[contenthash].css",
},
optimization: {
splitChunks: {
cacheGroups: {
styles: {
type: /^css/,
name: "styles",
chunks: "all",
enforce: true,
},
},
},
},
};Theme switching with constructable stylesheets
src/index.js
import dark from "./theme-dark.css" with { type: "css" };
import light from "./theme-light.css" with { type: "css" };
const media = matchMedia("(prefers-color-scheme: dark)");
const apply = () => {
document.adoptedStyleSheets = [media.matches ? dark : light];
};
media.addEventListener("change", apply);
apply();Each theme is a separate CSSStyleSheet, so swapping them costs no re-parse and no flash of unstyled content.
Development server with hot-reloaded styles
webpack.config.js
export default {
mode: "development",
experiments: { css: true },
devServer: {
hot: true,
},
};CSS changes are applied in place; there is no style-loader to configure and no separate HMR wiring.
Importing a package's stylesheet
/* src/styles.css */
@import "bootstrap";Webpack looks for a style field in the package's package.json and falls back to main, so bare specifiers work in CSS the way they do in JavaScript.
Known limitations
Native CSS is stable and on by default. It does not copy every option of the deprecated CSS loaders; anything that does not work as documented is a bug — please report it.
- A few loader options have no drop-in switch:
css-loader'slocalIdentRegExpand filter callbacks, andstyle-loader'sattributes/insert/styleTagTransform. Keep the loader for files that need them. (getLocalIdentmaps to thelocalIdentNamefunction form, andgetJSON/SSR is covered by matching class names across targets.) importLoadershas no equivalent — loaders in the chain apply to@imported files automatically.- If your project relies on advanced loader chains, validate each part before migrating fully.



