Skip to main content

Cloudflare Worker (Hono) + Supabase + React SPA monorepo

Build a CF Worker Hono + Supabase + React SPA pnpm monorepo. Build-and-typecheck playbook for a pnpm TypeScript monorepo containing a Cloudflare Workers API (Hono), a React SPA, and a shared packages/ layer backed by Supabase. Full error transcripts + reproduction recipes are in references/error-transcripts.md. A complete end-to-end product blueprint (webhook signature verification, queue-based send lifecycle, tokenized customer gate, RLS patterns, billing/tier enforcement, compliance) is in references/reviewloop-microsaas-architecture.md.

Monorepo shape that works​

apps/web React 18 SPA (Vite + TanStack Query + Tailwind) → deploy to Pages
apps/worker Hono API + scheduled cron handler (single Worker, single codebase)
packages/shared Zod schemas / types / pure business logic / date & money math
supabase/migrations forward-only SQL + RLS
  • Put all pure business logic (escalation engine, date math, money, Zod schemas, entitlement tables) in packages/shared so it's unit-tested independent of I/O.
  • Keep the escalation/scan logic pure (zero I/O); write a thin dispatcher around it in the worker. This is the highest-value habit: it makes the product logic fully unit-testable and is where the spec's worked examples belong.

Pitfalls & fixes (verified)​

1. @cloudflare/workers-types conflicts with lib.dom (TS6200 garbage)​

Symptom: hundreds of TS6200 duplicate identifier errors between @cloudflare/workers-types and lib.dom.d.ts (sometimes triggered only when tests/ is in the tsconfig because vitest pulls DOM types). skipLibCheck does NOT suppress TS6200. Fix:

  • Pin @cloudflare/workers-types to v3 (e.g. ^3.14.0). v4 auto-includes DOM refs and conflicts hard.
  • Give the worker its own tsconfig.json extending the base with "types": ["@cloudflare/workers-types"] and do NOT add WebWorker/DOM to lib (workers-types provides the globals; adding a DOM lib re-introduces the conflict).
  • Keep tests/ out of the worker tsconfig include. Let vitest transform test files itself; the Fast unit-only typecheck stays clean.
  • The patch tool's inline "lint" may re-surface DOM noise because it resolves a broader tsconfig — judge by the worker's own tsc --noEmit, not the noise.

2. extends must be TOP-LEVEL in tsconfig.json​

Symptom: TS5023 Unknown compiler option 'extends'. Cause: extends was nested inside compilerOptions. It is a top-level key.

{ "extends": "../../tsconfig.base.json", "compilerOptions": { ... }, "include": [...] }

3. supabase-js generics are unusable via ReturnType<typeof createClient>​

Symptom: SupabaseClient<unknown,{PostgrestVersion},never,...> type mismatches on every helper signature; .insert({...}) rejects with never[]. Fix: define a single loose alias and cast the client to it:

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type DB = SupabaseClient<any, any, any, any, any>;
export function makeDb(env: Env): DB {
return createClient(url, key, {...}) as DB; // or `as unknown as DB` in cron files
}

Use DB everywhere a client is passed; never use type-only SupabaseClient (default generics) for helper params, and never ReturnType<typeof createClient>.

4. Hono typing — define AppEnv, not Hono<AppContext['Variables']>​

Symptom: Property 'Variables' does not exist on type 'AppContext'. Fix: define a named env type and use it for every router + the root app:

export interface AppVariables { user: User; tenant: Tenant; auth: AuthUser }
export type AppEnv = { Bindings: Env; Variables: AppVariables };
export type AppContext = Context<AppEnv>;
const app = new Hono<AppEnv>(); // root app too
routerRoutes = new Hono<AppEnv>(); // per router
  • app.onError(handler) handler should take plain Context (hono), not AppContext, to bind with the root fetch.
  • c.json(obj, err.status) needs err.status as <literal status>; Hono wants a ContentfulStatusCode union, not number.
  • Workers ExecutionContext vs Hono's — cast ctx as never when passing to app.fetch.
  • Avoid import('...').Type annotations for a type used in a function param; bring a top-level import type { SupabaseClient } from '@supabase/supabase-js' instead (ESLint consistent-type-imports will flag the inline form).

5. pnpm 11 blocks every command on ignored build scripts​

Symptom: even pnpm typecheck runs a pre-run pnpm install that exits 1 with ERR_PNPM_IGNORED_BUILDS (workerd, esbuild, sharp), so nothing runs. Fix (any of):

  • Whitelist them in pnpm-workspace.yaml: onlyBuiltDependencies: [esbuild, workerd, sharp, ...]
  • Or set in ~/.npmrc: verify-deps-before-run=false and ensure-lockfile-before-run=false.
  • Remove duplicate "packageManager" keys in root package.json (esbuild warns on dup key).
  • Resolution caveat: node_modules/.bin/vitest etc. often resolve only from the package's node_modules, not the root — run test bins via ./node_modules/.bin/vitest from the package dir, or through pnpm --filter <pkg> test.

6. WebCrypto crypto.subtle won't compile under BOTH node and webworker libs​

Symptom: packages/shared uses crypto.subtle (HMAC tokens), typechecks fine alone under the node lib, but the worker (webworker lib + @cloudflare/workers-types) fails with TS2345: Uint8Array<ArrayBufferLike> not assignable to BufferSource — or vice versa. Adding "lib": ["ES2022","DOM"] to shared fixes shared but floods the worker with the TS6200 lib.dom-vs-workers-types conflict from pitfall #1. Fix: keep shared on the plain node lib and cast the crypto inputs through never so the same source compiles under both libs — do NOT add DOM anywhere:

const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret) as never,
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const sig = await crypto.subtle.sign('HMAC', key, data as never); // data: Uint8Array

Same as never technique applies anywhere BufferSource/ArrayBufferView<ArrayBuffer> generics differ between node and webworker libs (webhook HMAC signing, sha256). The never cast is force-multi-lib compatible without importing DOM.

7. Don't drop a tsconfig.json under tests/ — vitest treats it as a transform target​

Symptom: after adding apps/worker/tests/tsconfig.json (with a relative extends), the fast unit run fails with TSConfckParseError: failed to resolve "extends" ... in .../tests/tsconfig.json and "No test files" — even files unrelated to it. Vitest walks up / scans for tsconfigs and tries to transform-resolve them. Fix: don't add a tsconfig under tests/. Let the src tsconfig cover test files (or the integration config reference them). If you must, put extends on the correct relative path (../../tsconfig.base.json from apps/worker/tests/) — but deleting it is cleaner. Also run integration via its own config: "test:integration": "vitest run --config vitest.integration.config.ts".

Repository / git touches​

  • Add .env.example + .dev.vars.example and whitelist them in .gitignore (!**/.env.example, !.dev.vars.example) — broad .* ignore patterns silently drop them.
  • Set git user.name/user.email before the first commit if not already configured (GH-authed boxes often have the credential helper but no identity).
  • Two distinct fine-grained-PAT repo-creation failures (don't conflate them):
    1. Lacks createRepository → gh repo create fails with GraphQL: Resource not accessible ... (createRepository) or 403. Add the origin remote and have the user create the repo in the web UI, then git push -u origin main. Diagnose via gh repo list (works) vs gh repo create (throws).
    2. Has createRepository but the SCOPED token isn't granted access to the new repo. This is the sneaky one: gh repo create succeeds, but git ls-remote/push and GET /repos/{owner}/{repo} return 403 Write access to repository not granted / 404, and the repo never appears in gh repo list. Root cause: a fine-grained PAT is scoped to an explicit repo allowlist (Settings → Developer settings → Fine-grained tokens → Repository access → "Only select repositories") and is NOT auto-granted to repos it just created. Symptom fingerprint: create said the name already exists, but no tooling can see or write it. Fix: the user must add the new repo to the PAT's repository access list (then re-push). Only push to a repo you've verified you can ls-remote — the create-success message alone is not proof of access. Both PATs (gh CLI + MCP) may independently be scoped, so check both when diagnosing.

Verification​

  • pnpm -r typecheck, pnpm lint, pnpm test:unit all green before first commit.
  • Separate the integration-test vitest config (vitest.integration.config.ts) so the fast unit gate doesn't depend on a live Supabase; integration skips gracefully when SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY are unset.

Spec-contradiction rule​

When a spec's pseudocode contradicts its own worked examples / acceptance criteria, implement the examples (they encode intent) and document + test the choice — e.g. "created X days out fires rung Y" beats an ambiguous min() vs max() pseudocode line.


Supporting files: this skill's supporting files are held in the docsite at docs/15-skills/_support/software-development/cf-worker-hono-supabase-monorepo/ — fetch them fresh from jknash/docsite main alongside this page. Source: jknash/hermes-shared-skills · branch hermes-jkdev001 @ 1d0d545c3970 · skills/software-development/cf-worker-hono-supabase-monorepo/ · view source · Imported 2026-10-04. Supporting files (references, scripts) remain in the source repository.

Published by Muse · 2026-10-04.