WABBIT · ESSAYS
Why every Payload page ships every block's CSS, and how we cut ours from 444 KB to 112 KB
Next.js can't know which blocks a CMS page will use, so it sends them all; here's how we made each page load only the CSS for the blocks it renders.
The number
Every CMS page on wabbit.com renders through one Next.js route. When we started on our own blocks, each of those pages linked 444 KB of CSS, gzipped, before it could paint. Most of it styled blocks the page never rendered: the pricing page carried the styles for our case-study blocks, and every case study carried the pricing tables.
That figure came after we'd already converted the Tome block packs, which took raw CSS per page from 2,965 KB to 2,668 KB over four releases. What was left was mostly our own site blocks, 223 stylesheets under src/blocks and src/heros, and every one of them shipped on every page.
If your Payload site renders pages through a catch-all route and a blocks field, it's almost certainly doing the same thing.
Why one route ships everything
Payload pages are content. An editor picks blocks in the admin, and a single [slug] page renders whatever the document's layout field holds, through a map from blockType to component. That map imports every block component, and every block component imports its stylesheet.
Next decides a route's CSS at build time, from the imports it can reach. It can't know which blocks a document will use, because an editor chooses them after the build. So it does the only safe thing: it assumes any page might render any block, merges every block's CSS into the route's stylesheets, and links all of them on every page.
You might object that your blocks already use CSS Modules, so their styles are already per component. Ours did too; all 227 site blocks had their own .module.scss. CSS Modules solve naming, not delivery. They keep class names from colliding, and Next still merges every module the route can reach into that route's CSS.
What we ruled out
The cheap fix is dynamic imports: load each block with import() and hope its CSS follows it. We built that and measured it against a local copy of production. CSS over the wire went from 518 KB to 519 KB. The App Router collects CSS from dynamic imports in the server graph too, so lazy-loading a component doesn't lazy-load its styles.
We also considered keeping the bundle and moving it off the critical path, with a small inlined critical sheet and the rest loaded without blocking render. Every page would still download everything, and the App Router has no supported critical-CSS hook, so it would have needed an HTML post-process and risked unstyled content below the fold. We dropped it before building it.
The third option was a separate .css file per block, linked only where the block renders. Its real advantage is cross-page browser caching. The catch is that server-only blocks never pass through the client compiler, so the files would land in server output nobody serves, and every site installing a Tome pack would have to copy and serve them itself.
The fix: compile each block's CSS on its own and let React hoist it
What worked was taking block stylesheets out of Next's CSS pipeline. A bundler loader compiles each block's Sass, applies the same CSS Modules scoping Next uses, and returns a JS module: the class map you'd get from a CSS Module, carrying the compiled CSS with it. The block renders that CSS itself, so it only reaches pages that render the block.
React 19 handles the "only once" part. A <style> with an href and a precedence is hoisted into <head>, deduped by href, and ordered by its precedence group. This is the whole render side of Tome's helper:
export function BlockStyles({ sheet }: BlockStylesProps): ReactElement | null {
const compiled = readBlockSheet(sheet)
if (!compiled) return null
if (compiled.dev) return <style data-tome-block={compiled.href}>{compiled.css}</style>
return (
<style href={compiled.href} precedence="tome-block">
{compiled.css}
</style>
)
}A block that appears three times on a page puts its CSS in <head> once. The tome-block group lands after Next's own stylesheets, so a block rule wins a specificity tie against global CSS. In next dev the helper renders a plain inline <style> instead, because Turbopack dev gives Next's CSS chunks their own precedence groups and a hoisted block style could land between them.
One Next detail decided the file extension. The App Router sends every .css or .scss import from a server component through its own CSS pipeline, keyed on the extension, before any Turbopack rule applies. A .module.scss file can't be delivered per block under Turbopack, so block stylesheets are .tome-scss (or .tome-css), and the site registers the loader with one wrapper in next.config.js:
import { withTomeBlockStyles } from '@wabbit/tome-blocks-core/next'
// …
withPayload(
withTomeBlockStyles(nextConfig, {
aliases: { '@/': path.join(projectRoot, 'src') + path.sep },
loadPaths: ['src/styles'],
}),
{ devBundleServerPackages: false },
)A block reads the way it did before, plus one line. Here's our closing CTA block, trimmed:
import { BlockStyles } from '@wabbit/tome-blocks-core/block-styles'
import { FormBlock } from '@/blocks/Form/Component'
import styles from './CtaSplit.tome-scss'
// …
return (
<>
<BlockStyles sheet={styles} />
<section className={cn(styles.root, bg.className, formThemeClass, className)}>
{/* … */}
</section>
</>
)The .tome-scss file is ordinary SCSS with scoped classes and @use. Because authoring didn't change, converting our 223 site stylesheets took two commits.
You don't need Tome for the idea. In any React 19 + Next app you can compile a component's CSS yourself, render <style href precedence> from the component, and let React do the hoisting and dedupe.
What broke, and the rule it left us
We checked every wave with pixel diffs against main: 10 pages at 1440 and 390 wide, in dark and light. The diff caught the form inside that CTA block rendering unstyled on /pricing.
The form's stylesheet is imported by several files: the FormBlock client component and its field components. Our first codemod rendered the sheet only in the server components that imported it. Wherever FormBlock mounted without them, nothing rendered the sheet, so the form had its class names and no CSS.
The fix became a rule: every file that imports a block stylesheet renders it. That sounds wasteful until you remember the dedupe. React keys on href, so five components rendering one sheet still put it in <head> once. The form component now does exactly that:
import styles from './Form.tome-scss'
// …
return (
<>
<BlockStyles sheet={styles} />
<div className={cn(styles.formBlock, bg.className)} style={bg.style}>A file that's always mounted by a sibling that renders the sheet, such as an effect hook inside its own block, is the one exception. It names that sibling in a comment, // block-styles: rendered by ./Sibling, and the build check confirms the sibling really renders it.
Check a few other limits before you copy this. Relative url() doesn't work, because inlined CSS would resolve it against the page URL, so the loader fails the build on it; use absolute paths, full URLs or custom properties. The cascade also moves: block styles now load after all bundled CSS, so a global rule that used to beat a block rule by loading later stops winning. That's the case the pixel diffs were watching for. And a stylesheet imported by a client component carries its CSS text in that component's client JS, so it can render the same style on hydration and after client-side navigation.
Result: 112 KB, and a guard that keeps it there
After the site waves went live, linked CSS per page on wabbit.com fell from 444 KB to 112 KB gzip.
That number leaves something out. Each page's own block CSS now arrives inline in the HTML, and it can't be cached across pages the way a linked file can. On the pages we measured locally it added 14 to 24 KB gzip. The shell, tokens and globals stay in linked stylesheets the browser caches, so what's inlined is only the block CSS that page renders, one copy each.
The guard runs in prebuild, so a violation fails the build. For src/blocks and src/heros it checks two rules. No block imports CSS the old way, as a CSS Module or a side-effect stylesheet; two shared helper sheets are exempt because their helpers hand class names to about 180 blocks and render nothing themselves. And every file that imports a .tome-scss renders it, or names the sibling that does. A block that slips back to .module.scss would put its CSS on every page again, and the build stops before that ships.
Try it
Tome's block packs ship their stylesheets already compiled to plain JS modules, so a site that only installs packs gets per-block CSS with a stock next.config. You need withTomeBlockStyles only when you write block stylesheets of your own. The setup, the editor and lint config, and the full list of limits are in the "Per-block stylesheets" section of the blocks-core docs, and the blocks themselves are in the block library.
More in this series: Payload CMS SEO in Next.js: the static sitemap that hid 55 pages from Google and Adding a custom block to Payload's layout builder (Payload 3 + Next.js)