Content is user-generated and unverified.

How web frameworks split the CSS inline-vs-extract tradeoff

No two major frameworks agree on the right default. The spectrum runs from "always extract" (Vite, SvelteKit, Remix, SolidStart) to "always inline component CSS during SSR" (Nuxt) to hybrid threshold-based approaches (Astro at 4 KB, Qwik at 10 KB). The core tension — fewer requests vs. cross-page caching — gets resolved differently depending on each framework's rendering model, routing architecture, and opinions about first paint. What follows is a precise breakdown of every major framework's CSS bundling architecture.

Vite: the extraction-first foundation everyone inherits

Vite extracts CSS into separate .css files by default. Every async JS chunk gets an associated CSS file, loaded via <link> before the chunk evaluates (preventing FOUC). The key setting is build.cssCodeSplit: true (default), which creates per-chunk CSS files. Setting it to false concatenates all CSS into a single file.

Vite has no size-based threshold for inlining CSS into HTML. The commonly referenced build.assetsInlineLimit (default 4096 bytes) applies only to static assets like images — CSS always goes through the code-splitting pipeline. Shared CSS across routes is handled by Rollup's (or Rolldown's) automatic common-chunk extraction: if two route chunks import the same CSS, Rollup deduplicates it into a shared CSS chunk. Vite instruments dynamic imports with a preload helper that fetches CSS and JS dependencies in parallel, avoiding waterfalls. During SSR builds, CSS files are not emitted by default (the client build handles asset emission), but build.ssrManifest generates a mapping of modules to their CSS dependencies so the server knows which <link> tags to inject.

Astro pioneered a smart size-based threshold

Astro is the clearest example of the hybrid approach. Since v3.0, build.inlineStylesheets defaults to "auto": stylesheets smaller than vite.build.assetsInlineLimit (4 KB by default) are inlined as <style> tags; larger ones remain as external <link> tags. You can override to "always" (inline everything) or "never" (all external).

Astro builds per-page CSS chunks plus shared chunks. Each page gets its own CSS bundle, and styles shared across multiple pages are split into separate reusable chunks. Under "auto" mode, small shared chunks get inlined rather than requiring extra HTTP requests — this is where the threshold really pays off, since cross-page shared chunks are often tiny. Component <style> tags are scoped via data attributes ([data-astro-cid-*]) and extracted at build time. The inlineStylesheets setting works identically for both SSG and SSR modes. One known edge case: with "always" mode, CSS for lazy-loaded island components can be duplicated (inlined in the page HTML and also bundled with the component's JS).

SvelteKit exposes the most explicit control via inlineStyleThreshold

SvelteKit defaults to full extractioninlineStyleThreshold is 0 by default, meaning no CSS gets inlined. When set to a positive number, all CSS files for a page that are smaller than that value (measured in UTF-16 code units, i.e., String.length) get merged into a single <style> block in the HTML <head>. Setting it to Infinity inlines everything, which the docs note is useful for AMP pages where <link rel="stylesheet"> is prohibited.

SvelteKit forces build.cssCodeSplit: true internally and will not let you disable it. This produces aggressively granular CSS — each Svelte component with a <style> block generates its own CSS chunk, scoped via compiled class names (svelte-*). A common complaint is that this creates 10+ tiny CSS files per route, which can hurt FCP on slow connections. The inlineStyleThreshold exists specifically to mitigate this by merging small chunks into the HTML. The docs explicitly name the tradeoff: "fewer initial requests and improved First Contentful Paint, but larger HTML and reduced browser cache effectiveness."

SvelteKit also offers output.bundleStrategy (since v2.13.0) with three modes: "split" (default, lazy per-route), "single" (one JS + one CSS file for the entire app), and "inline" (everything embedded in HTML for serverless static use). Caveats with inlineStyleThreshold: relative URLs in CSS (e.g., to fonts) can break when CSS moves from a file into an inline <style>, and CSP nonce/hash handling needs attention.

Next.js chunks aggressively but only recently added inlining

Next.js defaults to external CSS files with automatic route-based code splitting. In production, CSS is concatenated into minified .css files representing "hot execution paths" — each route gets <link> tags for only the CSS it needs. The cssChunking config controls granularity: true/"loose" (default) aggressively merges CSS to minimize request count, while "strict" preserves import order at the cost of more chunks.

The experimental.inlineCss option is all-or-nothing: when enabled, it replaces all <link rel="stylesheet"> with <style> tags. There is no size-based threshold. A notable limitation: when navigating between statically generated pages with inlineCss enabled, Next.js falls back to external CSS to avoid duplicating styles across every HTML file. CSS also appears in the React Server Component payload, so on first load it exists in both the inline <style> and the RSC data.

In the App Router, CSS architecture follows the component tree. Layout CSS persists across child route navigations — a layout importing CSS keeps that CSS active for all nested pages, functioning as an implicit shared chunk. The Pages Router is coarser: global CSS must be imported in _app.js as a single bundle, with CSS Modules split per component. Next.js has no built-in critical CSS extraction (the old critters-based experimental.optimizeCss is deprecated). The rendering mode (SSR vs. SSG vs. CSR) does not change CSS delivery — the same .css files are referenced everywhere.

Remix gives routes explicit control over their own CSS

Remix with Vite produces external CSS files exclusively in production — there is no option to inline CSS in production builds. It offers two parallel CSS approaches with different architectural implications:

  • CSS URL imports + links(): Each route module declares its CSS via a links() export. Parent route CSS persists while children are active. Crucially, CSS <link> tags are removed from the DOM when navigating away from a route — this is unique among the frameworks listed and prevents CSS accumulation.
  • Side-effect imports: Standard import "./styles.css" — Vite bundles these and Remix auto-attaches them to the correct routes. Styles are not removed on navigation.

In development, Remix inlines CSS as <style> tags during SSR to prevent FOUC, then Vite's HMR takes over on the client. This causes momentary duplication on first load (not considered a bug). Route-based CSS code splitting is automatic via Vite — the Remix plugin reads the Vite manifest to inject correct <link> tags during SSR. Shared CSS lives in root route imports or gets deduplicated by Vite's chunking.

Nuxt 3 inlines component CSS by default — with known duplication pain

Nuxt 3 is the most aggressive inliner by default. features.inlineStyles (previously experimental.inlineSSRStyles) is enabled with the filter (id) => id.includes('.vue'), meaning all Vue component styles are inlined as <style> tags during SSR. This is not size-based — it is identity-based, applying to any .vue file. Global CSS from the nuxt.config.css array is still emitted as an external entry.<hash>.css file alongside the inlined component styles.

This dual approach causes a known duplication problem: component CSS can appear both inlined in the HTML and in the external CSS bundle, causing the browser to parse it twice. The issue is especially painful with nuxt generate (static generation), where all CSS gets inlined into every page's HTML — frameworks like Bootstrap can bloat each static page by 15K+ lines of CSS that should be a single cached external file. The nuxt-vitalizer community module exists to mitigate this by removing the render-blocking external CSS link when styles are already inlined. You can disable inlining entirely with features: { inlineStyles: false } or pass a custom filter function.

SolidStart and Qwik sit at opposite ends of the opinion spectrum

SolidStart is the least opinionated framework here — it defers almost entirely to Vite defaults. CSS is extracted to separate files, there are no inline thresholds, and no critical CSS extraction during SSR. The maintainers have acknowledged this gap: "Frameworks with SFCs have always handled their CSS a bit better than we have." Known issues include FOUC in development (Vinxi stopped pre-processing CSS in dev due to performance costs) and CSS ordering differences between dev and production builds. CSS for lazily loaded components may not be properly pre-included in SSR responses because Vinxi's manifest crawling stops at dynamic imports.

Qwik takes a unique threshold-based approach tied to its resumability model. Global CSS files under 10 KB are automatically inlined; files ≥ 10 KB are extracted. This is the only framework with an explicit, documented size threshold in the double-digit kilobyte range. Component styles loaded via useStyles$() and useStylesScoped$() are part of Qwik's lazy-loading graph — they are treated as QRLs (Qwik Resource Locators) that can be prefetched by the service worker and loaded on demand. The runtime tracks which styles are already loaded to prevent duplication.

Angular (worth noting for its mature approach) has inlined critical CSS by default since v12 using Critters (now Beasties). Unlike size-based thresholds, Critters uses selector matching: it parses the rendered HTML DOM against the full CSS and inlines all declarations whose selectors match elements in the document. Non-critical CSS is loaded asynchronously via a media="print" onload="this.media='all'" pattern. This runs as a post-rendering step for both SSR and prerendered pages. The cost is O(N × S) complexity for N DOM nodes and S selectors, which causes notable build slowdowns for large stylesheets (250 KB+) with large DOMs.

The architectural pattern behind the defaults

FrameworkDefaultInline thresholdCSS code splittingShared CSS strategy
ViteExtract to filesNonePer async chunkRollup common chunks
AstroHybrid auto4 KB (assetsInlineLimit)Per page + sharedPer-page + deduped shared chunks
SvelteKitExtract to filesinlineStyleThreshold: 0 (off)Per component (forced)Per-component via Rollup
Next.jsExtract to filesNone (all-or-nothing inlineCss)Per routeLayout persistence + bundler merging
RemixExtract to filesNonePer route via ViteRoot route + links() lifecycle
Nuxt 3Inline .vue stylesIdentity-based filterPer route via ViteInlined components + external entry.css
SolidStartExtract to filesNonePer async chunk via ViteVite/Rollup defaults
QwikHybrid10 KBPer component (lazy QRLs)Global + per-symbol chunks
AngularInline critical CSSSelector-match basedPer component encapsulationCritters critical + async remainder

Conclusion: no consensus, but clear clusters

Three distinct strategies have emerged. The extraction-first camp (Vite, SvelteKit, Remix, SolidStart) prioritizes cacheability and clean architecture, relying on HTTP/2 multiplexing to mitigate the extra-request cost. The inline-first camp (Nuxt, Angular) prioritizes first-paint speed, accepting larger HTML and cache duplication. The threshold-based hybrid camp (Astro at 4 KB, Qwik at 10 KB) tries to get the best of both — inlining tiny chunks that aren't worth a round-trip while extracting larger files that benefit from caching. SvelteKit's inlineStyleThreshold is the most user-tunable knob in this space, while Astro's "auto" mode is the most zero-config hybrid. The industry is gradually moving toward hybrids, but the "right" threshold depends entirely on your page count, CSS volume, and whether users typically visit one page or many.

Content is user-generated and unverified.
    CSS Inlining vs Extraction: How Web Frameworks Compare | Claude