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/sharedso 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-typesto v3 (e.g.^3.14.0). v4 auto-includes DOM refs and conflicts hard. - Give the worker its own
tsconfig.jsonextending the base with"types": ["@cloudflare/workers-types"]and do NOT addWebWorker/DOMtolib(workers-types provides the globals; adding a DOM lib re-introduces the conflict). - Keep
tests/out of the worker tsconfiginclude. 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 plainContext(hono), notAppContext, to bind with the root fetch.c.json(obj, err.status)needserr.status as <literal status>; Hono wants aContentfulStatusCodeunion, notnumber.- Workers
ExecutionContextvs Hono's — castctx as neverwhen passing toapp.fetch. - Avoid
import('...').Typeannotations for a type used in a function param; bring a top-levelimport type { SupabaseClient } from '@supabase/supabase-js'instead (ESLintconsistent-type-importswill 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=falseandensure-lockfile-before-run=false. - Remove duplicate
"packageManager"keys in root package.json (esbuild warns on dup key). - Resolution caveat:
node_modules/.bin/vitestetc. often resolve only from the package'snode_modules, not the root — run test bins via./node_modules/.bin/vitestfrom the package dir, or throughpnpm --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.exampleand whitelist them in.gitignore(!**/.env.example,!.dev.vars.example) — broad.*ignore patterns silently drop them. - Set git
user.name/user.emailbefore 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):
- Lacks
createRepository→gh repo createfails withGraphQL: Resource not accessible ... (createRepository)or403. Add theoriginremote and have the user create the repo in the web UI, thengit push -u origin main. Diagnose viagh repo list(works) vsgh repo create(throws). - Has
createRepositorybut the SCOPED token isn't granted access to the new repo. This is the sneaky one:gh repo createsucceeds, butgit ls-remote/pushandGET /repos/{owner}/{repo}return403 Write access to repository not granted/404, and the repo never appears ingh 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 canls-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.
- Lacks
Verification
pnpm -r typecheck,pnpm lint,pnpm test:unitall 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 whenSUPABASE_URL/SUPABASE_SERVICE_ROLE_KEYare 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 fromjknash/docsitemain alongside this page. Source:jknash/hermes-shared-skills· branchhermes-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.