Giving a headless CMS a head on Cloudflare Workers

Minima is currently only a headless CMS, but I’ve been hard at work giving it a head.

It will launch with a small set of official themes, but after seeing how good Gemini 3 was at design, it was obvious the infrastructure also had to support AI-generated themes.

The original plan was a single, multi-tenant “customer” Nuxt application. It would pull a site’s theme from the database and compile the templates into Vue components at runtime, but otherwise be a relatively normal Nuxt app.

Nuxt excludes the Vue runtime compiler by default, but it can be enabled via vue.runtimeCompiler, and there are even tests covering what I was trying to do.

In local development, the entire pipeline worked flawlessly.

Then we deployed to Cloudflare.

On-the-fly template compilation is not supported in the ESM build of @vue/server-renderer. All templates must be pre-compiled into render functions.

Uh, what?

Here’s the relevant code in @vue/server-renderer, note the TODO:

// TODO: this branch should now work in ESM builds, enable it in a minor
if (!__CJS__) {
  throw new Error(
    `On-the-fly template compilation is not supported in the ESM build of ` +
      `@vue/server-renderer. All templates must be pre-compiled into ` +
	    `render functions.`,
  )
}

Armed with false hope and Claude Code, I eventually found a workaround – using a Nitro hook and a Rollup plugin to force Nuxt to bundle the CJS build of @vue/server-renderer instead of the ESM one:

export default defineNuxtConfig({
  vue: {
    runtimeCompiler: true,
  },

  experimental: {
    externalVue: false,
  },

  hooks: {
    // Bundle the CJS build of @vue/server-renderer
    'nitro:config': (config) => {
      config.rollupConfig = config.rollupConfig || {}
      config.rollupConfig.plugins = config.rollupConfig.plugins || []
      config.rollupConfig.plugins.push({
        name: 'vue-server-renderer-cjs',
        resolveId(id) {
          if (id === '@vue/server-renderer') {
            const cjsPath = require.resolve('@vue/server-renderer/dist/server-renderer.cjs.js')
            return { id: cjsPath, external: false }
          }
          return null
        },
      })
    },
  },
})

This introduced us to the final boss:

Code generation from strings disallowed for this context.

Here’s the line that triggers it:

return (compileCache[cacheKey] =
  Function('require', code)(fakeRequire))

Turns out, that is a complete non-starter on Cloudflare at the time of writing.

I built a minimal repro, confirmed the approach works fine when targeting Node, and concluded that the absolute best-case scenario on Cloudflare would be that we could make it work – without SSR.

That wasn’t acceptable.

Back to the drawing board

SSR is non-negotiable, and I really didn’t want to host customer sites on a different platform just to make this work.

Eventually I remembered that Cloudflare had already solved a very similar problem in vibesdk: generating and running code on Workers.

So how did they do it?

I cloned the repo into .tmp and pointed Claude Code at it.

The answer involves a combination of:

The (somewhat simplified) flow looks like this:

  • Download a Nuxt app scaffold from R2

  • Retrieve the site’s theme from D1

  • Codegen the Vue components

  • Build the Nuxt application

  • Deploy the compiled app and assets to Workers for Platforms

The “customer app” no longer renders anything itself. It’s now a lightweight dispatch worker that routes requests to each customer’s isolated Nuxt application.

This supports both:

A blessing in disguise

Runtime compilation meant instant theme updates. Build-time compilation meant scary new infrastructure.

But looking at it now, the trade-off is actually an improvement:

  • Customer sites are fully isolated

  • The full build + deploy pipeline takes ~90 seconds on average, without any real optimization yet

  • The runtime compilation work still powers live previews inside the CMS

  • Dropping the Vue runtime compiler means smaller bundles for customer sites

Onwards.

© 2025 Tim Hanlon