Nuxt 4: SSR, Full Prerender, and Inline Critical CSS — What Worked and What Didn't
💡 Background
This blog runs on Nuxt 4 + Nuxt Content + Nuxt UI. In production, the deployment model is:
ssr: true— SSR stays on so dev experience and dynamic routing are unaffected- Public content pages are fully prerendered at build time, so first paint behaves like static HTML
On top of that, we wanted to inline critical CSS. Three approaches looked reasonable:
render:response+ Beasties — mutate HTML during the SSR response phasevite-plugin-beasties— run during the Vite build- Run Beasties in
nitro:build:public-assetsagainst prerender output
This post explains why the first two fail, why the third works, and the implementation details — including the data-beasties-container hydration pitfall.
❌ Why the First Two Approaches Fail
1. render:response + Beasties
render:response is a Nitro SSR hook that runs after server rendering completes and before the response is sent. Hooking Beasties here during build-time prerender causes:
- unhead risk — Beasties mutates the HTML string directly (inserting inline
<style>, rewriting<link>tags) outside unhead's<head>pipeline. Output order is no longer guaranteed, and client hydration can end up with a broken<head>structure or duplicated styles. - Nuxt UI conflicts — Nuxt UI writes theme colors via inline styles such as
#nuxt-ui-colors. Beasties'<head>rewrites can clash with those, making hydration mismatches worse.
2. vite-plugin-beasties
This plugin runs during the Vite build and processes HTML files Vite emits. In a Nuxt + Nitro pipeline, Vite finishes the client bundle first; Nitro then prerenders each route into separate .html files.
The plugin only sees Vite's entry HTML — not Nitro's per-route prerender output. Vite runs before Nitro prerender, so even a processed entry HTML is not the same as the final per-page prerender artifacts.
✅ Why nitro:build:public-assets + Beasties Works
The key difference: don't mutate HTML in the SSR response chain — post-process static files after prerender has finished writing them to disk.
- Correct timing — Nitro prerenders each route and writes
.htmlfiles tooutput.publicDir, thennitro:build:public-assetsruns. Beasties reads the HTML that will actually ship to the CDN — not the Vite entry template, and not a pre-prerender intermediate. - Full coverage —
prerender:generaterecords every successfully generated.htmlpath for per-file processing. Multilingual content pages, list pages, and detail pages each get their own prerender output, so you avoid the "only the entry HTML was optimized" problem. - SSR hot path untouched — the module skips entirely in dev. In production, dynamic routes that were not prerendered never hit this hook. Beasties only runs on registered static pages; dev and runtime SSR behavior stay the same.
- Avoids unhead / Nuxt UI conflicts — the first two approaches fail because they rewrite
<head>before the head pipeline has settled. Post-build processing works on static HTML where SSR, unhead, and Nuxt UI inline styles are already written to disk. You optimize deployment artifacts, not compete with response-time head management. - Failures are isolated — if Beasties fails on one page, it logs a warning and does not block the build. External CSS remains in the HTML, so the page still has full styles as a fallback.
That keeps responsibilities clear: SSR / prerender produces correct HTML; Beasties speeds up first paint on static HTML at the end of the build — aligned with "full prerender of public pages, first paint like static HTML."
🛠️ Implementation
Prerequisite: ssr: true alongside nitro.prerender.
Dev is plain SSR; production public pages are static at build time.
Four pieces: Nuxt / Nitro config, module wiring, Beasties config, and hydration alignment.
1. Nuxt / Nitro Config
Prerender-related settings in nuxt.config.ts (production only; dev routeRules stay empty so local debugging is unaffected):
ssr: true,
routeRules: isDev
? {}
: {
"/**": { prerender: true }, // full prerender of public pages in production
"/_vercel/image": { prerender: false }, // Vercel image optimization route — explicitly excluded
},
nitro: {
prerender: {
// Scan prerendered HTML for in-site <a href> links and follow them automatically
crawlLinks: true,
// HTML may reference /_vercel/image?… (e.g. LCP preload); exclude to avoid accidental prerender
ignore: [/^\/_vercel\/image/],
},
},
2. Module Setup
modules/build-prerender.ts does three things outside dev:
prerender:routesCallscollectPrerenderRoutes()to add content routes to the prerender list. BesidescrawlLinks: true, it explicitly collects static pages and non-draft posts per locale. On a multilingual site, content file paths often do not match public URLs one-to-one; relying on the crawler alone can miss pages.prerender:generateRecords successfully generated.htmlpaths ingeneratedFiles.nitro:build:public-assetsIteratesgeneratedFilesand runs Beasties only on prerendered HTML that succeeded.
One non-obvious detail: official docs describe this hook as running "after public assets are copied, before the Nitro server build completes." Here, server build means rollup bundling the production server — not the entire Nitro lifecycle. In practice, prerender finishes and HTML is on disk before this hook runs, so Beasties reads final prerender output.
Beasties input is final HTML from disk. If generatedFiles is empty, processing is skipped. Per-file failures log a warning without failing the build.
Core structure:
// Record relative paths of successfully prerendered .html files
const generatedFiles = new Set<string>();
// Supplement routes crawlLinks may miss (multi-locale, non-draft posts, etc.)
nuxt.hooks.hook("prerender:routes", async ({ routes }) => {
const discovered = await collectPrerenderRoutes(nuxt.options.rootDir);
for (const route of discovered) {
routes.add(route);
}
});
// Wait for Nitro init, then attach prerender:generate
nuxt.hooks.hook("nitro:init", (nitro) => {
// Register each successfully prerendered page's .html path
nitro.hooks.hook("prerender:generate", (route) => {
if (!route.fileName?.endsWith(".html") || route.error) return;
generatedFiles.add(withoutLeadingSlash(route.fileName));
});
});
// After prerender completes and public assets are copied, inline critical CSS per file
nuxt.hooks.hook("nitro:build:public-assets", async (nitro) => {
const beasties = new Beasties({
path: nitro.options.output.publicDir,
publicPath: nitro.options.baseURL,
...beastiesConfig,
});
for (const file of generatedFiles) {
const htmlPath = resolve(nitro.options.output.publicDir, file);
const contents = await readFile(htmlPath, "utf-8");
const processed = await beasties.process(contents);
await writeFile(htmlPath, processed); // overwrite prerender output on disk
}
});
3. Beasties Config
config/beasties.ts uses conservative settings: speed up first paint without treating external CSS as the sole source. Inline comments explain each false:
export const beastiesConfig = {
// Inline critical CSS only; do not change external <link> tags —
// avoids fighting Nuxt/unhead head hydration
preload: false,
// Do not prune rules already inlined from external entry.css;
// client navigation and runtime styles still rely on the full CSS fallback
pruneSource: false,
// Do not inline @font-face or preload fonts via Beasties;
// fonts follow existing CSS / asset strategy
fonts: false,
// Process only styles from external <link> tags;
// skip SSR inline styles (e.g. Nuxt UI #nuxt-ui-colors)
reduceInlineStyles: false,
};
4. Hydration and data-beasties-container
Beasties can use data-beasties-container to narrow the scope of critical CSS evaluation. The docs recommend placing it on the outermost wrapper around above-the-fold content inside <body>, so Beasties matches selectors against that subtree only — useful for large or deeply nested pages at build time.
If the attribute is missing, Beasties falls back to <html> and writes data-beasties-container itself — effectively evaluating the whole document.
This project sets it on <html> in app/app.vue. The main goal is not scoping, but hydration alignment:
- After Beasties processes prerender HTML, the attribute remains in the output
- If the SSR template lacks the same attribute, client hydration may see a DOM mismatch
If pages grow and build-time Beasties gets slow, a better optimization is moving data-beasties-container to an above-the-fold wrapper inside <body> (e.g. the outer div around hero + nav), rather than keeping it on <html>.
Relevant useHead snippet in app/app.vue:
<script setup lang="ts">
useHead(() => ({
htmlAttrs: {
// Beasties writes this during build-time prerender; SSR output must match.
"data-beasties-container": "",
},
}));
</script>
📋 Summary
With SSR on + full prerender of public pages, the hard part of inlining critical CSS is not "can we use Beasties?" but when, and on which HTML.
Two approaches that fail:
render:response+ Beasties — mutates<head>in the SSR response chain, outside unhead's pipeline; clashes with Nuxt UI inline styles (e.g.#nuxt-ui-colors); high hydration risk.vite-plugin-beasties— Vite build runs before Nitro prerender; the plugin only handles Vite entry HTML, not per-route prerender output.
What works: run Beasties in nitro:build:public-assets on prerendered HTML on disk — module skipped in dev, SSR hot path untouched in prod. Clear split: SSR / prerender produces correct pages; Beasties only accelerates first paint on deployment artifacts.
Verify: after npm run build, inspect .output/public/index.html for inline <style> plus retained external stylesheet <link> tags; build logs should show Beasties processed N prerendered HTML file(s).
Scope: Beasties only reduces CSS blocking first paint. Image LCP, deferred JS hydration, API caching, and similar concerns still need separate optimization layers.