Skip to content

Instantly share code, notes, and snippets.

@jokull
Last active March 2, 2026 20:09
Show Gist options
  • Select an option

  • Save jokull/86b96b0f29eae9bf7a8df43a122bdb3b to your computer and use it in GitHub Desktop.

Select an option

Save jokull/86b96b0f29eae9bf7a8df43a122bdb3b to your computer and use it in GitHub Desktop.
Hurdles migrating a Next.js blog to vinext (Cloudflare Workers)

Migrating a Next.js blog to vinext (Cloudflare Workers): Hurdles

This documents the struggles encountered migrating a Next.js 15 blog (with MDX, Shiki syntax highlighting, and OG image generation) to vinext on Cloudflare Workers.

1. EvalError: Code generation from strings disallowed

The showstopper. @mdx-js/mdx's run() internally calls new Function() to evaluate compiled MDX. Workers completely blocks eval and new Function at request time.

EvalError: Code generation from strings disallowed for this context

This was initially misdiagnosed as a Shiki/WASM issue because the error was silently caught (catch { mdx = null }). Adding console.error to the catch block and checking wrangler tail revealed the real culprit.

Fix: Replaced @mdx-js/mdx with safe-mdx, which parses MDX to an AST and renders React elements without code generation.

Regression: MDX expressions and inline JS in posts no longer evaluate. Custom JSX components (<Card>, <Tool>, etc.) still work via the components map.

2. Shiki WASM engine fails on Workers

Shiki's default Oniguruma engine loads WASM at runtime for TextMate grammar parsing. Workers can't dynamically instantiate WASM at request time.

Fix: Switched to shiki/engine/javascript — a pure JS RegExp engine — with pre-bundled language grammars from @shikijs/langs/*. No WASM needed.

import { createHighlighterCore } from "shiki/core";
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
import langJsx from "@shikijs/langs/jsx";
// ... other langs

const highlighter = createHighlighterCore({
  themes: [cssVariablesTheme],
  langs: [langJsx, langTsx, langTypescript, langBash, ...],
  engine: createJavaScriptRegexEngine(),
});

Regression: JS RegExp engine may highlight some edge-case grammars differently vs Oniguruma. Fine for a blog.

3. Module-level Node.js imports crash Workers startup

OG image routes had top-level import { readFileSync } from "fs" and import { ImageResponse } from "next/og". Workers validates all module-level code at deploy time — even if the route is never hit.

Invalid URL string at line 99129
new URL("./noto-sans-v27-latin-regular.ttf", import.meta.url)

Fix: Lazy-import everything inside the async handler:

export default async function Image({ params }) {
  const post = await getPost(slug);
  const { ImageResponse } = await import("next/og");
  const { readFileSync } = await import("fs");
  const { join } = await import("path");
  // ...
}

Also removed export const runtime = "nodejs" directives.

4. Turso → Cloudflare D1

The original blog used Turso (libsql over HTTP via @libsql/client/web). This pulled in cross-fetchnode-fetch → Node HTTP internals, requiring shims and Vite aliases. The whole thing was fragile.

Fix: Switched to Cloudflare D1 with native bindings. D1 is a first-class Cloudflare primitive — no HTTP transport, no fetch shims, no @libsql/client aliasing. Just a wrangler.jsonc binding and drizzle-orm/d1:

// db.ts
import { drizzle } from "drizzle-orm/d1";
import { env } from "cloudflare:workers";

export function getDb() {
  _db ??= drizzle(env.DB, { schema });
  return _db;
}

// Lazy proxy so `import { db }` works at module level
export const db = new Proxy({} as DrizzleD1Database<typeof schema>, {
  get(_, prop) {
    const real = getDb();
    const value = real[prop as keyof typeof real];
    return typeof value === "function" ? value.bind(real) : value;
  },
});
// wrangler.jsonc
"d1_databases": [{
  "binding": "DB",
  "database_name": "solberg-blog",
  "database_id": "..."
}]

This eliminated @libsql/client, cross-fetch, the cross-fetch shim, and two Vite resolve aliases.

5. Environment variables aren't available at module init

Cloudflare Workers populates process.env (via nodejs_compat) only when handling requests — not during module evaluation. Module-level Zod validation fails because env vars are undefined at startup.

Fix: Lazy proxy that defers validation to first access:

export const env = new Proxy({} as Env, {
  get(_, prop: string) {
    if (!_env) {
      const result = envSchema.safeParse(process.env);
      if (result.success) _env = result.data;
      else return process.env[prop]; // fallback during startup
    }
    return _env[prop as keyof Env];
  },
});

For D1 and other Cloudflare bindings, use import { env } from "cloudflare:workers" instead of process.env. This is a virtual module provided by @cloudflare/vite-plugin.

6. @cloudflare/vite-plugin + vinext interaction

The Cloudflare Vite plugin sets the "workerd" condition for module resolution. It required specific viteEnvironment config to work with vinext:

cloudflare({
  viteEnvironment: {
    name: "rsc",
    childEnvironments: ["ssr"],
  },
}),

There was also a back-and-forth with proxy.ts middleware (for auth route protection) — removed, then restored, then adjusted to use named exports to match vinext's expectations.

7. Vite bakes env values at build time

Unlike Next.js which handles process.env at runtime, Vite replaces process.env.KEY with string literals during build. If you build with .env pointing at localhost:9797, that URL is hardcoded into the production bundle forever.

Fix: Use .env.production with production values. Vite loads it automatically in production mode. Secrets stay in wrangler config (not baked).

8. cssom CJS circular requires crash Vite's module runner

safe-mdxlinkedomcssom@0.5.0. The cssom package (unmaintained since 2017) has circular require() calls between parse.js, CSSStyleSheet.js, and CSSStyleRule.js. Node tolerates partially-initialized circular CJS modules; Vite's CJS module runner does not:

TypeError: Cannot read properties of undefined (reading '__cjs_module_runner_transform')
    at CSSStyleRule.js

Fix: Pre-bundle cssom for the RSC and SSR environments so Vite converts CJS → ESM at build time, eliminating the circular require issue:

// vite.config.ts
environments: {
  rsc: {
    optimizeDeps: { include: ["cssom"] },
  },
  ssr: {
    optimizeDeps: { include: ["cssom"] },
  },
},

This is a general technique: any CJS package with circular requires that crashes Vite's module runner can be fixed by adding it to optimizeDeps.include for the relevant environments.


The commit story tells it

7f02dd4 Migrate from Next.js to vinext (Cloudflare Workers)
ed5570e Remove Cloudflare plugin, patch vinext, delete proxy
e96fcdf Restore proxy.ts middleware for auth route protection
1f27b77 Use named export for proxy to match main branch
ad48fbd Fix MDX rendering on Cloudflare Workers
278a21e Adapt remaining files for Cloudflare Workers runtime
4a74627 Migrate from Turso/libsql to Cloudflare D1 and fix cssom crash

Each commit is a "fix the fix" — the migration was iterative, not a clean one-shot. The real blockers were all runtime environment differences that only surface after deploy.

TL;DR

Workers is not Node.js. The things that bit hardest:

  1. No eval/new Function — kills any library that generates code from strings at runtime
  2. No dynamic WASM — must use pure JS alternatives or pre-bundle
  3. No Node.js APIs at module level — lazy-import everything
  4. CJS circular requires — Vite's module runner can't handle them; pre-bundle with optimizeDeps
  5. Env vars aren't available at startup — defer all init to request time; use cloudflare:workers bindings for D1/KV/etc.
  6. Use native Cloudflare primitives — D1 over Turso eliminates entire categories of shim/alias problems
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment