A blog prob­a­bly of inter­est only to nerds by John F Mor­ton.

Ink of the day: MIXING… 

SuperGeekery: A blog probably of interest only to nerds by John F Morton.

Goodbye Puppeteer, Hello Beasties: Simpler Critical CSS for Craft CMS

▷ Audio edition

The narration of this post was created with Bespoken plugin for Craft CMS.

For a long time my Craft CMS build had a small but per­sis­tent embar­rass­ment buried inside it: a config.applesilicon.yaml file for DDEV that installed a small zoo of Lin­ux libraries just so a head­less Chrome could ren­der my pages and extract crit­i­cal CSS.

Here is what that file looked like:

webimage_extra_packages:
  [
    libasound2,
    libatk1.0-0,
    libcairo2,
    libgtk-3-0,
    libnspr4,
    libpango-1.0-0,
    libpangocairo-1.0-0,
    libx11-xcb1,
    libxcomposite1,
    libxcursor1,
    libxdamage1,
    libxfixes3,
    libxi6,
    libxrandr2,
    libxrender1,
    libxss1,
    libxtst6,
    fonts-liberation,
    libnss3,
    xdg-utils,
    chromium
  ]
web_environment:
  - CPPFLAGS=-DPNG_ARM_NEON_OPT=0
  - PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
  - PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true

What a mess of depen­den­cies. Sigh. 

That con­fig exist­ed for one rea­son: I was using rollup-plugin-critical, which leans on Pent­house, which leans on Pup­peteer, which wants its own Chromi­um bina­ry. On an Apple Sil­i­con Mac run­ning DDEV, none of that was going to work out of the box. So I taught DDEV to install Chromi­um inside its con­tain­er, taught Pup­peteer where to find it, and crossed my fin­gers.

When it worked, it was great. When it did­n’t, I was read­ing con­tain­er logs try­ing to fig­ure out which library went miss­ing in a Debian update.

The fun did­n’t stop in local. My pro­duc­tion serv­er is, char­i­ta­bly, mod­est. It is a small instance run­ning Lar­avel Forge, and giv­ing it a head­less Chrome process to run dur­ing deploys was always a lit­tle opti­mistic. Some­times the crit­i­cal CSS step would just give up halfway through a deploy. I’d find out from a slight­ly slow­er Light­house score the next morn­ing.

I want to talk about how I got rid of all that. The short ver­sion: I switched to Beast­ies, a main­tained fork of Google’s critters, and my build is now bor­ing in the best pos­si­ble way.

Why critical CSS at all?

If you already know, skip ahead. If not: crit­i­cal CSS is the bit of your stylesheet that styles the part of the page a vis­i­tor sees before they scroll. Inlin­ing it in the <head> lets the brows­er paint that area before it has fin­ished down­load­ing the full CSS file. It is one of the cheap­est per­for­mance wins avail­able, and it makes Light­house smile at you.

The down­side is that you have to gen­er­ate it. Some­thing has to look at each unique page lay­out, fig­ure out which selec­tors actu­al­ly apply above the fold, and write that sub­set of CSS to disk. His­tor­i­cal­ly that some­thing” has been a head­less brows­er ren­der­ing the page for real.

The old approach

With rollup-plugin-critical, the work hap­pened inside the Vite build. A Rollup plu­g­in would, dur­ing the build:

  1. Spin up Pup­peteer.
  2. Launch a head­less Chrome.
  3. Vis­it a list of URLs I’d con­fig­ured.
  4. Use Pent­house to fig­ure out which CSS rules were used above the fold.
  5. Write those rules to web/dist/criticalcss/<template>_critical.min.css.

My Twig lay­out then inlined the match­ing file via nystudio107/craft-vite:

{{ craft.vite.includeCriticalCssTags() }}

The out­put was great. Get­ting there was the prob­lem. Pup­peteer is a heavy­weight depen­den­cy, Chrome is a heavy­weight run­time, and build my site” turned into build my site and run a brows­er clus­ter.” Local­ly I need­ed that config.applesilicon.yaml to keep DDEV hap­py. On pro­duc­tion I need­ed enough RAM and patience for Chrome to behave. When either piece mis­be­haved, the build either failed loud­ly or — worse — suc­ceed­ed with no crit­i­cal CSS, and I did­n’t notice until lat­er.

I want to be fair here: rollup-plugin-critical is doing real work and using Pent­house because Pent­house is gen­uine­ly good at this. The trou­ble is not the plu­g­in. The trou­ble is what it asks the sur­round­ing envi­ron­ment to pro­vide.

Enter Beasties

Beast­ies takes a dif­fer­ent approach. Instead of ren­der­ing each page in a real brows­er, it pars­es the HTML and the CSS as text and works out which selec­tors could apply to the ele­ments in the doc­u­ment. No brows­er, no GPU, no Chromi­um. Just Node pars­ing strings.

That trade-off has a cost — Beast­ies does not lit­er­al­ly know what is above the fold, only what is in the HTML — but in prac­tice the out­put is close enough to what Pent­house would have pro­duced, and the oper­a­tional pro­file is dra­mat­i­cal­ly sim­pler.

Con­crete­ly, here is what changed in my project:

The whole thing now runs in pure Node. My pro­duc­tion serv­er thanked me.

What the new setup looks like

The script is small enough to read in one sit­ting. The struc­ture is:

  1. Use dotenv to load CRITICAL_URL from .env. This is the pub­lic URL the script will fetch HTML from — for me, my live site.
  2. Read the Vite man­i­fest (web/dist/.vite/manifest.json) to fig­ure out which built CSS file Vite pro­duced this time around. The file­name has a con­tent hash in it, so this needs to be looked up rather than hard-cod­ed.
  3. For each page I care about, fetch the HTML over HTTP, rewrite the stylesheet link so it points at the fresh­ly built CSS on disk, and hand the doc­u­ment to Beast­ies.
  4. Pull the inlined <style> block back out of the Beast­ies out­put and write it to web/dist/criticalcss/<template>_critical.min.css.

My pages array tells the script which URLs map to which tem­plates:

const pages = [
  { uri: '/', template: 'index' },
  { uri: '/uses', template: '_pages/_page' },
  { uri: '/blog/extracting-a-youtube-id-from-a-url-with-twig', template: '_posts/_entry' },
  { uri: '/', template: 'standalone' },
]

Each entry is one rep­re­sen­ta­tive URL per unique lay­out. The blog post URL is a sin­gle entry; the script does not need to crawl every post, because every post uses the same tem­plate.

The Beast­ies con­fig itself is short:

const beasties = new Beasties({
  path: join(projectRoot, 'web'),
  publicPath: '/',
  reduceInlineStyles: true,
  preload: 'none',
  fonts: true,
  logLevel: 'info',
})

The Twig side did not change at all. craft.vite.includeCriticalCssTags() still picks the right file based on the cur­rent tem­plate.

What it feels like in practice

Local: npm run build:critical. Done. No DDEV gym­nas­tics, no installing Chromi­um into a con­tain­er, no PUPPETEER_EXECUTABLE_PATH. The build runs to com­ple­tion every time.

Pro­duc­tion: my deploy script runs npm run build:critical with CRITICAL_URL point­ed at the live site. The fetch­es are cheap. There is no brows­er process to crash. There is no Chromi­um bina­ry to keep in sync with Debian. If the net­work or the live site is down dur­ing a deploy, the script tells me so clear­ly instead of dying in a stack trace ten lay­ers deep.

I won’t pre­tend Beast­ies pro­duces iden­ti­cal out­put to a Pup­peteer-based tool. Pent­house real­ly does ren­der the page and mea­sure the view­port, which is a gen­uine­ly dif­fer­ent (and in some ways more accu­rate) sig­nal. But for a con­tent site like this one, the dif­fer­ence in the result­ing crit­i­cal CSS is small, the visu­al result is good, and the oper­a­tional dif­fer­ence is enor­mous.

A small thing for fellow Craft + Vite folks

If you’d like to try the same set­up on your own Craft + Vite project, I also wrote a sin­gle Mark­down file you can drop into your repo and hand to Claude Code or Codex. It tells the agent to look around your project, ask you a few ques­tions about your URLs and tem­plates, and then wire up the same Beast­ies set­up I’m describ­ing here. You can grab it from this gist. If you try it and it does some­thing weird, let me know in the Craft Dis­cord.

How are you han­dling crit­i­cal CSS in 2026? I’m curi­ous whether the rest of you have already left Pup­peteer behind, or whether I’m late to my own par­ty.