Skip to content
PraxisSign in

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. In the rail, the node under discussion lights as you read.

Request topology of the B2B SaaS StarterbrowserHTTP clientMCP clientqueue jobswebTanStack StartapiREST + MCPbackgroundqueue consumerpackages/capabilitiesD1QueuesEmail
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

HTTP client

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 the deploy command. pnpm run deploy.

The three infrastructure bindings this 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

Optional providers

These integrations are optional and stay inactive until configured.

Stripe

Stripe

Environment-gated

Sentry

Sentry

Environment-gated

PostHog

PostHog

Environment-gated

Cloudflare Email

Email

Environment-gated

Turnstile

Turnstile

Environment-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.

FAQ

Answers about billing, licensing, and adopting the B2B SaaS Starter for your product.

TanStack Start runs natively on a Cloudflare Worker without a Node adapter, ships file-based routing with strongly typed loaders, and stays close to the underlying Web Fetch API. It composes cleanly with Effect v4 server functions and avoids the dual Edge/Node runtime split.

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