Skip to content

A Cloudflare-first B2B SaaS starter

The hard parts, already wired.

Workspaces, auth, REST + MCP, webhooks, email, audit, and admin, typed end-to-end and proven by a working reference app. It boots locally with zero provider secrets.

  1. vp install
  2. pnpm run db:migrate:local
  3. pnpm run db:seed
  4. pnpm run dev
  • TanStack Start
  • Effect v4
  • Drizzle D1
  • Better Auth
  • shadcn/ui
  • Tailwind v4
  • Cloudflare Workers
  • Alchemy v2

One request, traced end to end.

The same read that filled the numbers above, followed from the curl that starts it to the binding that persists it. Every excerpt below is real code from this repository; the caption on each panel is its path.

Request topology of the B2B SaaS Starterbrowsercurl / SDKMCP clientqueue jobsapps/webWorker · TanStack Startapps/apiWorker · REST + MCPapps/backgroundWorker · queue consumerpackages/capabilitiesD1QueuesEmailB2B-SAAS-STARTER · REQUEST TOPOLOGYCLOUDFLARE-FIRST · ALCHEMY v2 · SHEET 1/1
Clients
browser, curl / SDK, MCP client, queue jobs
Workers
apps/web (TanStack Start), apps/api (REST + MCP), apps/background (queue consumer)
Shared layer
packages/capabilities: every worker calls the same effects
Infrastructure
D1 (database), Queues (outbound webhooks), Email Service

curl / SDK

The request

An ordinary bearer-authenticated GET. The body printed below is the live overview this page rendered its numbers from, not a fixture: the first notification is shown in full and the rest are counted, never paraphrased.

REST · GET /workspaces/:slug/overview
curl -H "Authorization: Bearer bsk_live_xxx" \
  https://api.example.com/workspaces/starter-lab/overview

{
  "workspace": {
    "id": "wrk_starter",
    "slug": "starter-lab",
    "name": "Starter Lab",
    "planId": "team"
  },
  "notifications": []
}

apps/api

The contract

The route exists because the contract says it does. Path, params, success schema, and the typed error channel sit in one declaration: WORKSPACE_ERRORS is WorkspaceNotFound, Unauthorized, AuthorizationDenied, RateLimited, CapabilityUnavailable — encoded on the endpoint, not thrown as strings. The bearer gate rides the group, so a sibling endpoint cannot ship without it.

HttpApiEndpoint · the workspace grouppackages/api/src/index.ts
export const WorkspaceApi = HttpApiGroup.make('workspace')
  .add(
    HttpApiEndpoint.get('overview', '/workspaces/:slug/overview', {
      params: SlugParams,
      success: WorkspaceOverviewDto,
      error: WORKSPACE_ERRORS
    })
  )
  .middleware(BearerAuth)

packages/capabilities

The capability, written once

Both surfaces call the same effect. Its failure channel and its service requirements are part of its type, so every caller shares one failure vocabulary and the compiler checks the wiring — the claim on this page that cannot be faked.

Effect · the overview projectionpackages/capabilities/src/workspace-projections.ts
export const workspaceOverview: Effect.Effect<
  WorkspaceOverviewProjection,
  CapabilityUnavailable,
  WorkspaceContext | NotificationFeed
> = Effect.gen(function* () {
  const ctx = yield* WorkspaceContext
  const feed = yield* NotificationFeed
  const notifications = yield* feed.list
  return {
    workspace: ctx.workspace,
    notifications
  }
})
server fnapps/web/src/lib/server/demo-showcase.effects.ts
return runWorkspaceCapabilities(
  DEMO_WORKSPACE_SLUG,
  Effect.all({ overview: workspaceOverview, memberCount: … })
)
REST handlerapps/api/src/handlers.ts
.handle('overview', ({ params, request }) =>
  workspaceRead(READ_OPERATIONS.overview, params, undefined, request)
)
MCP toolapps/api/src/mcp.ts
const invoke = yield* decodeOperationInput(operation, payload)
yield* requirePermission(yield* callerPrincipal(caller), operation.permission)
return yield* invoke

D1 · Queues · Email

The runtime it lands on

Every binding below the effect is declared once in alchemy.run.ts: the same TypeScript description provisions local dev and production, so the whole story ends in pnpm run deploy.

The three infrastructure bindings the trace ends in, one row per node in the schematic
bindingwhat it holdsdeclared in
D1SQLite: schema, migrations, seed rowspackages/db
QueuesWebhook deliveries with retries and backoffapps/background
EmailTransactional sends, provider-gated until configuredpackages/email

Every provider is optional.

Stripe, Sentry, PostHog, Cloudflare Email, and Turnstile ship with real integrations that stay inactive until their env vars exist. Local development never blocks on a provider account.

Stripe

Billing checkout, portal, and webhooks

env-gated

Sentry

Error monitoring across all three Workers

env-gated

PostHog

Product analytics — browser and all three Workers

env-gated

Cloudflare Email

Outbound transactional email

env-gated

Turnstile

Bot protection on public forms

env-gated

The reasoning is checked in.

Docs, FAQ, and blog are versioned MDX in the repo, searched from generated indexes, no CMS. The blog explains why each technology call was made, and releases are cut by release-please.

Fork it. Local in 4 commands.

MIT licensed. The reference application runs locally against a seed workspace: no Stripe key, no OAuth app, no email domain required.

clone and quickstart

Copied
$ git clone
https://github.com/brandhaug/b2b-saas-starter.git
$ vp install
vp install
$ pnpm run db:migrate:local
pnpm run db:migrate:local
$ pnpm run db:seed
pnpm run db:seed
$ pnpm run dev
pnpm run dev
web
http://localhost:3071
api
pnpm -C apps/api dev
background
pnpm -C apps/background dev
providers
env-gated: nothing to configure