---
title: "Goodbye Puppeteer, Hello Beasties: Simpler Critical CSS for Craft CMS"
date: 2026-06-07T09:00:00-04:00
author: John Morton
canonical_url: "https://supergeekery.com/blog/critical-css-without-chrome-how-i-traded-puppeteer-for-beasties-craft-cms"
section: Blog
---
# Goodbye Puppeteer, Hello Beasties: Simpler Critical CSS for Craft CMS

*June 7, 2026* by John Morton

![Puppeteer to beasties](https://static.supergeekery.com/site-assets/puppeteer-to-beasties.png)

*Audio narration available for this post.*

The audio version of this post excludes reading big blobs of code. See the written blog post for those bits and bobs.

For a long time my Craft CMS build had a small but persistent embarrassment buried inside it: a `config.applesilicon.yaml` file for DDEV that installed a small zoo of Linux libraries just so a headless Chrome could render my pages and extract critical CSS.

Here is what that file looked like:

```plaintext
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 dependencies. Sigh.*

That config existed for one reason: I was using [`rollup-plugin-critical`](https://www.npmjs.com/package/rollup-plugin-critical), which leans on Penthouse, which leans on Puppeteer, which wants its own Chromium binary. On an Apple Silicon Mac running DDEV, none of that was going to work out of the box. So I taught DDEV to install Chromium inside its container, taught Puppeteer where to find it, and crossed my fingers.

When it worked, it was great. When it didn't, I was reading container logs trying to figure out which library went missing in a Debian update.

The fun didn't stop in local. My production server is, charitably, *modest*. It is a small instance running Laravel Forge, and giving it a headless Chrome process to run during deploys was always a little optimistic. Sometimes the critical CSS step would just give up halfway through a deploy. I'd find out from a slightly slower Lighthouse score the next morning.

I want to talk about how I got rid of all that. The short version: I switched to [Beasties](https://github.com/danielroe/beasties), a maintained fork of Google's `critters`, and my build is now boring in the best possible way.

## **Why critical CSS at all?**

If you already know, skip ahead. If not: critical CSS is the bit of your stylesheet that styles the part of the page a visitor sees *before they scroll*. Inlining it in the `<head>` lets the browser paint that area before it has finished downloading the full CSS file. It is one of the cheapest performance wins available, and it makes Lighthouse smile at you.

The downside is that you have to generate it. Something has to look at each unique page layout, figure out which selectors actually apply above the fold, and write that subset of CSS to disk. Historically that "something" has been a headless browser rendering the page for real.

## **The old approach**

With `rollup-plugin-critical`, the work happened *inside* the Vite build. A Rollup plugin would, during the build:

1. Spin up Puppeteer.
2. Launch a headless Chrome.
3. Visit a list of URLs I'd configured.
4. Use Penthouse to figure out which CSS rules were used above the fold.
5. Write those rules to `web/dist/criticalcss/<template>_critical.min.css`.

My Twig layout then inlined the matching file via [`nystudio107/craft-vite`](https://github.com/nystudio107/craft-vite):

```plaintext
{{ craft.vite.includeCriticalCssTags() }}
```

The output was great. Getting there was the problem. Puppeteer is a heavyweight dependency, Chrome is a heavyweight runtime, and "build my site" turned into "build my site *and* run a browser cluster." Locally I needed that `config.applesilicon.yaml` to keep DDEV happy. On production I needed enough RAM and patience for Chrome to behave. When either piece misbehaved, the build either failed loudly or — worse — succeeded with no critical CSS, and I didn't notice until later.

I want to be fair here: `rollup-plugin-critical` is doing real work and using Penthouse because Penthouse is genuinely good at this. The trouble is not the plugin. The trouble is what it asks the surrounding environment to provide.

## **Enter Beasties**

Beasties takes a different approach. Instead of rendering each page in a real browser, it parses the HTML and the CSS as text and works out which selectors *could* apply to the elements in the document. No browser, no GPU, no Chromium. Just Node parsing strings.

That trade-off has a cost — Beasties does not literally know what is above the fold, only what is in the HTML — but in practice the output is close enough to what Penthouse would have produced, and the operational profile is dramatically simpler.

Concretely, here is what changed in my project:

- I deleted `config.applesilicon.yaml`. Gone. No more weird package lists.
- I removed `rollup-plugin-critical` and all the Puppeteer machinery from `vite.config.js`.
- I added a single standalone Node script, `scripts/generate-critical-css.mjs`, that runs *after* the Vite build. ([https://gist.github.com/johnfmorton/7d886854f0a93fd4933b454f27851363](https://gist.github.com/johnfmorton/7d886854f0a93fd4933b454f27851363/revisions))
- I added two npm scripts: `generate-critical` to run the new script on its own, and `build:critical` to chain a build and a critical-CSS pass together.

The whole thing now runs in pure Node. My production server thanked me.

## **What the new setup looks like**

The script is small enough to read in one sitting. The structure is:

1. Use `dotenv` to load `CRITICAL_URL` from `.env`. This is the public URL the script will fetch HTML from — for me, my live site.
2. Read the Vite manifest (`web/dist/.vite/manifest.json`) to figure out which built CSS file Vite produced this time around. The filename has a content hash in it, so this needs to be looked up rather than hard-coded.
3. For each page I care about, fetch the HTML over HTTP, rewrite the stylesheet link so it points at the freshly built CSS on disk, and hand the document to Beasties.
4. Pull the inlined `<style>` block back out of the Beasties output and write it to `web/dist/criticalcss/<template>_critical.min.css`.

My `pages` array tells the script which URLs map to which templates:

```plaintext
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 representative URL per unique layout. The blog post URL is a single entry; the script does not need to crawl every post, because every post uses the same template.

The Beasties config itself is short:

```plaintext
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 current template.

## **What it feels like in practice**

Local: `npm run build:critical`. Done. No DDEV gymnastics, no installing Chromium into a container, no `PUPPETEER_EXECUTABLE_PATH`. The build runs to completion every time.

Production: my deploy script runs `npm run build:critical` with `CRITICAL_URL` pointed at the live site. The fetches are cheap. There is no browser process to crash. There is no Chromium binary to keep in sync with Debian. If the network or the live site is down during a deploy, the script tells me so clearly instead of dying in a stack trace ten layers deep.

I won't pretend Beasties produces *identical* output to a Puppeteer-based tool. Penthouse really does render the page and measure the viewport, which is a genuinely different (and in some ways more accurate) signal. But for a content site like this one, the difference in the resulting critical CSS is small, the visual result is good, and the operational difference is enormous.

## **A small thing for fellow Craft + Vite folks**

If you'd like to try the same setup on your own Craft + Vite project, I also wrote a single Markdown 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 questions about your URLs and templates, and then wire up the same Beasties setup I'm describing here. You can grab it from [this gist](https://gist.github.com/johnfmorton/b8c68455ac7644737d2c4d9d105207fd). If you try it and it does something weird, let me know in the Craft Discord.

How are *you* handling critical CSS in 2026? I'm curious whether the rest of you have already left Puppeteer behind, or whether I'm late to my own party.

---

**Tags:** craftcms, webperf, css

## Related Posts

- [Customizing the CKEditor in Craft CMS](https://supergeekery.com/blog/customizing-the-ckeditor-in-craft-cms)
- [The HTML email template in Craft CMS](https://supergeekery.com/blog/craftcms-html-email-template-missing-documenation)
- [Set Up Redis Caching in Craft CMS with DDEV: A Step-by-Step Guide](https://supergeekery.com/blog/set-up-redis-for-local-development-with-craft-cms)
