Cloudflare Workers is one of the most common edge deployment targets in 2026.
This recipe documents what runs in V8 isolates, how meta gen slots into a
Vite + Wrangler build chain, and the gotchas that took multiple sessions to
learn the first time around.
Workers run V8 isolates with a Node.js compatibility flag. Not every package is V8-isolate-safe. The table below is the authoritative split:
| Package | Workers-compatible? | Notes |
|---|---|---|
@metaobjectsdev/cli |
Build-time only | Run on the developer's machine; never bundled into the Worker. |
@metaobjectsdev/metadata |
Build-time only | Same. |
@metaobjectsdev/codegen-ts |
Build-time only | Same. |
@metaobjectsdev/migrate-ts |
Build-time only | Migrations emit SQL, then wrangler d1 migrations apply runs them. |
@metaobjectsdev/render |
✅ Runtime-safe | Pure JS, no Node-only deps. Used at request time. |
@metaobjectsdev/runtime-ts |
/hono entry |
The Fastify and Kysely entry points are Node-only. The /hono entry is what generated Hono routes import. |
@metaobjectsdev/runtime-web |
✅ Runtime-safe | Pure browser/edge JS. |
@metaobjectsdev/react |
✅ Runtime-safe | Browser; works in Workers if you render React on the edge. |
@metaobjectsdev/tanstack |
✅ Runtime-safe | Browser-side. |
Generated <Entity>.entity.ts / <Entity>.queries.ts |
✅ Runtime-safe | The generated code is the runtime; uses drizzle-orm/d1. |
Generated <Entity>.routes.ts (Fastify, default) |
❌ Node-only | The handlers are Workers-safe but the Fastify adapter from runtime-ts is not. Use the Hono generator instead. |
Generated Hono routes (routesFileHono()) |
✅ Runtime-safe | The opt-in Workers routes generator. It imports only the @metaobjectsdev/runtime-ts/hono entry point (Hono, Drizzle, Zod; no Fastify, Kysely or node: import in that module graph). Wire it in metaobjects.config.ts in place of routesFile(). |
The minimum-friction shape for a Workers + Vite + MO project:
project-root/
├── metaobjects/ # MO metadata (source of truth)
├── metaobjects.config.ts
├── src/
│ ├── worker.ts # Worker entrypoint (the SPA's API)
│ └── ui/ # React SPA source
│ ├── index.html
│ └── main.tsx
├── public/ # Hand-curated static (favicons, robots.txt, etc.)
├── dist/ # Vite output (gitignored)
└── wrangler.toml # Workers config
vite.config.ts must set build.emptyOutDir: false if you want hand-curated
files in dist/ to survive a vite build. With Vite 5+'s default of
emptyOutDir: true, the build wipes anything Vite didn't produce itself —
destroying any pre-staged assets, including files copied by other build
steps.
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: {
emptyOutDir: false, // ← critical for Workers Static Assets integration
outDir: "dist",
rollupOptions: {
input: { main: "src/ui/index.html" },
},
},
});wrangler.toml points assets.directory = "./dist" and main = "./src/worker.ts":
name = "myapp"
main = "./src/worker.ts"
compatibility_date = "2026-01-01"
compatibility_flags = ["nodejs_compat"]
[assets]
directory = "./dist"
binding = "ASSETS"
[[d1_databases]]
binding = "DB"
database_name = "myapp-prod"
database_id = "abc-123..."The dev-time loop:
$ meta gen # codegen runs locally; output is committed
$ vite dev # SPA dev server
$ wrangler dev # worker dev server (proxied for /api)The deploy loop:
$ meta gen # in case metadata changed; commit any drift
$ vite build # bundles the SPA into ./dist/
$ wrangler deploy # uploads worker + assetsmeta gen runs once per metadata change, not per build. The output is
committed. A CI gate can run meta gen then git diff --exit-code to
enforce that committed generated code matches the metadata — drift fails
the build.
$ meta migrate --dialect d1 --slug my-change # emits migrations/<seq>_my-change.sql
$ wrangler d1 migrations apply <BINDING> # local
$ wrangler d1 migrations apply <BINDING> --remote # productionmigrations_dir in wrangler.toml and --out-dir in meta migrate should
agree (default migrations/ matches Wrangler's expectation).
For the full D1 dialect documentation, see the
@metaobjectsdev/migrate-ts README.
D1 is SQLite. In metaobjects.config.ts set dialect: "sqlite": the generated tables are
sqliteTable, timestamps are ISO strings, and the routes use SQLite RETURNING. "d1" names
only the meta migrate transport (migrate.dialect in .metaobjects/config.json,
--dialect d1), which shells out to Wrangler. meta gen and meta verify --codegen refuse any
codegen dialect other than sqlite, postgres or mysql, and say so for "d1", rather than
quietly generating Postgres.
Use routesFileHono() (not the Fastify routesFile()) and queriesFile():
// metaobjects.config.ts
import { defineConfig } from "@metaobjectsdev/cli";
import { entityFile, queriesFile, routesFileHono, barrel } from "@metaobjectsdev/codegen-ts/generators";
export default defineConfig({
outDir: "src/generated",
dialect: "sqlite", // D1 is SQLite
extStyle: "js",
apiPrefix: "/api",
generators: [entityFile(), queriesFile(), routesFileHono(), barrel()],
});Neither generated module imports a module-level db. Both take it as an argument:
Note.queries.tsexportslistNotes(db, opts?),findNoteById(db, id),createNote(db, ...)and so on, each withdbas the first parameter.Note.routes.hono.tsexportsregisterNoteRoutes(app, { db }). Thedbis captured when the routes are mounted, not looked up per call.
A D1 binding only exists on env, inside fetch, so build the Drizzle client there and mount
the generated routes on a Hono app for that request:
// src/worker.ts
import { Hono } from "hono";
import { drizzle } from "drizzle-orm/d1";
import { registerNoteRoutes } from "./generated/Note.routes.hono.js";
import { listNotes } from "./generated/Note.queries.js";
interface Env {
DB: D1Database; // from @cloudflare/workers-types
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const db = drizzle(env.DB); // one client per request
const url = new URL(request.url);
if (url.pathname === "/health") {
return Response.json({ notes: (await listNotes(db, {})).length }); // queries: plain calls
}
const app = new Hono<{ Bindings: Env }>();
registerNoteRoutes(app, { db }); // routes: mounted with this request's db
return app.fetch(request, env, ctx);
},
};Registering the five routes is cheap, so doing it per request is fine. If you would rather mount
once, build the app lazily on the first request (let app: Hono | undefined) and keep it for the
isolate: the binding on env does not change for the life of the deployed version. Do not
mount at module scope, where env does not exist yet, and do not pass a placeholder such as
drizzle({} as never): it type-checks and then fails on the first query.
Add your guard before the mount: Guarding generated routes.
Wrangler needs only the binding the code reads:
# wrangler.toml
name = "myapp"
main = "./src/worker.ts"
compatibility_date = "2026-01-01"
compatibility_flags = ["nodejs_compat"]
[[d1_databases]]
binding = "DB" # env.DB above
database_name = "myapp-prod"
database_id = "<your-database-id>"
migrations_dir = "migrations" # where `meta migrate --dialect d1` writesGenerated from a one-entity model with dialect: "sqlite", then compiled with tsc --strict
(module: nodenext) together with the worker.ts above against the real drizzle-orm/d1,
hono and @metaobjectsdev/runtime-ts types. D1Database and ExecutionContext came from a
local stub in place of @cloudflare/workers-types, which that environment does not install.
The same per-request mount (a new Hono app and registerNoteRoutes per request) was run against
an in-memory libSQL database, which shares D1's SQLite dialect: POST /api/notes answered 201
and GET /api/notes answered 200 with the row. D1 itself, wrangler dev and a deployed Worker
were not exercised.