A starter template for full-stack apps on Cloudflare Workers with Vite 8, React 19, TanStack Start, Drizzle, Vitest 4, and pnpm. See AGENTS.md for the full command reference.
This README is both descriptive (what the repo enforces today) and prescriptive (the conventions you should keep if you adopt this starter). Commands and config details live in AGENTS.md; this file explains why they exist.
- Runtime: Cloudflare Workers (via
wrangler+@cloudflare/vite-plugin) - App: React 19, TanStack Start/Router, Chakra UI
- Build: Vite 8
- Tests: Vitest 4 (unit + browser + integration), Playwright (e2e), Stryker (mutation)
- DB: Drizzle ORM + Postgres
- Lint / format: oxlint, oxfmt
- Typecheck: layered (
tsconfig.domain.json,tsconfig.infra.json) - Codebase intelligence: fallow
- Git hooks: lefthook
pnpm install
pnpm devRun the test suites:
pnpm test # watch mode, all projects
pnpm test:unit # unit
pnpm test:browser # browser (Vitest + Playwright)
pnpm test:e2e # Playwright e2e
pnpm test:mutate # Stryker mutation testsFull quality gate (lint, format, typecheck layers, fallow audit):
pnpm run ciSee AGENTS.md for the complete command list and configuration details.
Business logic lives in pure TypeScript. Frameworks, databases, and external services are adapters plugged into ports. The goal is to express domain models and use cases without depending on the runtime, the HTTP server, the database driver, or the UI framework — so any of those can be swapped with near-zero rewrite to the core.
Layers:
src/domain/— one folder per subdomain of the application (billing/,auth/,account/, …). Each subdomain contains its own entities, value objects, use cases, and aports/folder holding the interfaces it needs from the outside world (user-repository.types.ts,event-publisher.types.ts). No imports fromsrc/infra/, no framework globals, no Node/Workers APIs. May import fromsrc/utils/.src/infra/— adapter implementations, grouped by technology concept and then by specific technology:database/postgres/user-repository.ts,messaging/kafka/event-publisher.ts. Files are named after the entity or capability (user-repository.ts,event-publisher.ts) — never the technology, because the folder already denotes it. Nesting the concrete tech inside the concept makes it obvious what each adapter is fulfilling and keeps the swap path (e.g.postgres/→sqlite/) local. Infra depends on domain ports; the reverse is never allowed.src/utils/— thin wrappers over third-party libraries (lodash,date-fns, …). The escape hatch that lets domain code use common utilities without defining a port per library. The utility module is the port; the library is its implementation, swappable at the utility boundary.src/api/— HTTP composition root (Hono). Wires concrete adapters into use cases and exposes them as routes.src/webapp/— React + TanStack Start UI. Calls into the API; composes its own adapters where needed.
The boundary is enforced four times, on purpose:
- Path aliases —
@domain/*and@infra/*intsconfig.jsonmake the layer of every import readable at a glance. - Layered typechecks —
tsconfig.domain.jsoncompiles the domain alone;tsconfig.infra.jsoncompiles domain + infra. If domain code reaches into infra, the domain typecheck fails before anything else runs.pnpm typecheck:layersruns both. - Custom oxlint rule —
starter/domain-no-infra-imports(intools/oxlint-plugins/rules/) blocks both static and dynamic imports fromsrc/infra/**insidesrc/domain/**files. - Fallow custom boundaries —
.fallowrc.jsondefines explicit zones fordomain,infra,api,webapp, andserver, then failsboundary-violationissues in CI.
Any one of the four would catch most mistakes; all four together make the violation loud and local.
src/
domain/ # one folder per subdomain; pure business logic + ports
billing/
ports/
invoice-repository.types.ts
invoice.ts
auth/
ports/
user-repository.types.ts
user.ts
account/
...
infra/ # adapters grouped by technology concept, then specific tech
database/
postgres/
user-repository.ts # named for the entity; folder denotes the tech
messaging/
kafka/
event-publisher.ts
utils/ # thin wrappers over 3rd-party libs (lodash, date-fns, ...)
api/ # Hono server — composes domain + infra
webapp/ # React + TanStack Start UI
server.ts # Cloudflare Workers entry
tools/
oxlint-plugins/ # custom lint rules (e.g. domain-no-infra-imports)
test/ # shared test setup (database harness, factories)
e2e/ # Playwright specs
Four test types. The filename tells you which, because it reads like the test type it is.
| Type | Filename | Purpose | Tool | Coverage expectation |
|---|---|---|---|---|
| Unit | *.unit.test.ts |
Behavioural units of domain logic | Vitest | 100% on domain |
| Browser | *.browser.test.ts |
UI components interacting with real DOM APIs | Vitest browser mode + Playwright | As needed per component |
| Integration | *.integration.test.ts |
Adapter implementations reaching real third-party boundaries — e.g. a Drizzle repository against a Postgres testcontainer, or an HTTP client against an MSW handler | Vitest (+ Testcontainers / MSW) | At least one per adapter |
| E2E | *.e2e.test.ts |
Full user flows across multiple routes — signup, login, the journeys that must always work | Playwright | Critical flows only (top priority) |
Examples from the repo: src/domain/shared/result.unit.test.ts, src/infra/drizzle/user-operations.integration.test.ts, e2e/home.e2e.test.ts, and the oxlint plugin's tools/oxlint-plugins/rules/domain-no-infra-imports.unit.test.ts.
Unit tests are the load-bearing layer: they run on every staged-file commit and every push, and they drive the 100% domain-coverage expectation. Integration tests verify that adapter code actually talks to the thing it claims to. E2E tests keep the most important journeys honest. Browser tests catch regressions in DOM-dependent behaviour that jsdom-style runners miss.
Mutation testing is the test-quality gate. Line coverage can show that tests executed code; Stryker checks whether those tests would fail if the code's behaviour changed.
Run it with:
pnpm test:mutateConfiguration lives in stryker.config.mjs. Stryker uses vitest.mutation.config.ts, not the main vitest.config.ts, so mutation testing runs only fast Node-based unit tests. This is deliberate: Stryker's Vitest runner does not support Vitest Browser Mode, and integration/e2e suites are too slow and environment-heavy for the mutation loop.
Current mutation scope is src/domain/** and src/api/**, excluding tests, declarations, and declaration-only *.types.ts / *.schema.ts modules. Expand the mutate globs only when the new code has fast behavioural unit tests; do not add browser, integration, generated, or adapter-only code to the default mutation target set.
The mutation score must stay at or above 80%. thresholds.break enforces this in Stryker, so pnpm test:mutate and the mutation CI workflow fail below that floor. HTML reports are written under reports/ for local investigation.
Filename suffixes encode module intent. They act as machine-readable tags: tooling can treat them uniformly (coverage exemptions, lint overrides, test discovery) and readers can see the shape of a module before opening it.
| Pattern | Purpose |
|---|---|
*.unit.test.ts |
Unit tests — behavioural units of domain logic. |
*.browser.test.ts |
Browser tests — UI components against real DOM APIs. |
*.integration.test.ts |
Integration tests — adapters against real third-party boundaries. |
*.e2e.test.ts |
End-to-end tests — full user flows across routes. |
*.schema.ts |
Validation schemas (Zod or similar). No branching logic. |
*.types.ts |
Type and interface declarations only — including port interfaces under src/domain/**/ports/. No *.interface.ts suffix. |
*.schema.ts and *.types.ts are exempt from unit-test coverage demands — they contain no behaviour to verify; unit-testing them would only restate the declarations. The explicit suffix makes that exemption self-documenting.
See Testing strategy for the tool and coverage expectation behind each test suffix.
Rule of thumb: if a module contains only declarations, use *.schema.ts or *.types.ts. If it contains behaviour, don't — and use the matching *.test.ts suffix for its tests.
Every stage has a specific job. Understanding the why matters as much as the commands.
- Pre-commit (lefthook) — runs
oxlint --fixandoxfmt --writeon staged files (auto-restaged), thenvitest related --run --project unitover the staged files andpnpm fallow:cifor Fallow checks. Why: keeps git history clean and readable (no "fix lint" commits), and ensures every commit is independently releasable — no commit silently breaks the behaviour or architecture of code near the change. - Pre-push (lefthook) —
vitest run --changed origin/master --project unit. Catches regressions across the whole change set before they leave the machine. pnpm run ci(local + CI) —oxlint && oxfmt --check . && pnpm typecheck:layers && pnpm fallow:ci. Read-only quality gate; no fixes, no writes. The source of truth for "is this branch green?"- GitHub Actions — runs the same gate plus the test matrix (unit, browser, integration).
- Mutation Tests workflow —
pnpm test:mutateon pull requests that touchsrc/domain/**,src/api/**, unit tests, or mutation config, with manual dispatch available. It fails below an 80% mutation score.
To skip hooks for a single command (e.g. an intentional WIP commit), set LEFTHOOK=0.
- Hono as the HTTP server — deliberately runtime-agnostic. If Cloudflare Workers stops fitting (a cold-start regression, a pricing change, a feature Workers can't express), the Hono app moves to Node / Bun / Deno / a container with near-zero rewrite.
- Cloudflare Workers as the starter runtime — chosen because it's exceptionally cheap and fast out of the box, with a global edge by default. Drizzle + Hyperdrive give Postgres access without managing a connection pool.
- The swap path —
src/api/is the composition boundary. Replacesrc/server.ts(the Workers entry) with a different runtime adapter to change runtime; the app factory does not change.
fallow is a Rust-native, sub-second whole-project analyzer for TypeScript/JavaScript. It finds things oxlint's per-file model can't see:
- Dead code — unused files, exports, types, dependencies
- Duplication — cross-file clone groups
- Complexity hotspots — cyclomatic + cognitive complexity, churn-vs-complexity hotspots
- Circular dependencies
- Architecture drift
Common commands:
pnpm fallow # full analysis (dead code + dupes + health)
pnpm fallow dead-code # unused code + circular deps only
pnpm fallow dupes # duplication scan
pnpm fallow health # complexity + maintainability
pnpm fallow list --boundaries
pnpm fallow dead-code --boundary-violations
pnpm fallow fix --dry-run # preview auto-fixes for unused exports/depspnpm run ci runs pnpm fallow:ci (fallow audit) as a quality gate — it scopes analysis to files changed against the base branch and returns a pass/warn/fail verdict. Configuration lives in .fallowrc.json, including custom boundary zones and rules for the hexagonal architecture. For the full feature set, see the fallow docs.
A short checklist for applying these conventions to a greenfield project:
pnpm init, set"packageManager": "pnpm@10.x".- Create
src/domain,src/infra,src/utilson day one, even if empty. Directory shape is a commitment. - Copy the
tsconfig.json+tsconfig.domain.json+tsconfig.infra.jsontrio; adjust the includes/aliases. - Set up
oxlint.config.tsandoxfmt.config.ts. Copytools/oxlint-plugins/and rename thestarternamespace. - Add
lefthook.ymlwith pre-commit (lint + format +vitest related --project unit+pnpm fallow:ci) and pre-push (vitest run --changed origin/master --project unit). - Add the
pnpm ciscript: lint → format check → layered typecheck → fallow audit. - Add Stryker (
@stryker-mutator/core,@stryker-mutator/vitest-runner),stryker.config.mjs,vitest.mutation.config.ts, and thepnpm test:mutatescript with an 80%thresholds.breakfloor. - Adopt the four test-type filename suffixes (
.unit,.browser,.integration,.e2e) before writing any tests. - Adopt
*.schema.ts/*.types.tsbefore introducing any declaration-only module. - Put your HTTP server behind Hono so the runtime stays swappable.
- Wire fallow with
.fallowrc.jsonto catch dead code and duplication as the project grows.
AGENTS.md— commands, imports, hook commands, and agent workflow notes..fallowrc.json— fallow configuration.stryker.config.mjsandvitest.mutation.config.ts— mutation testing configuration.tools/oxlint-plugins/README.md— the custom lint plugin, includingdomain-no-infra-imports.