A full-stack, edge-native starter kit: TanStack Start on the front, a Hono Worker API on the back, Better Auth on D1 in between. Everything runs on Cloudflare Workers — no containers, no Node server, no separate auth service.
| Layer | Choice |
|---|---|
| Web | TanStack Start (SSR) + React 19, TanStack Router / Query, Vite |
| API | Hono on Cloudflare Workers |
| Auth | Better Auth (11 plugins) — email/password, OAuth, passkeys, OTP, 2FA, orgs, SSO, API keys, MCP, admin |
| Database | Cloudflare D1 + Drizzle ORM |
React Email templates via Cloudflare Email Sending (send_email) |
|
| Billing | Polar (checkout, customer portal, usage, webhooks) — optional |
| UI | shadcn/ui on Base UI + Tailwind CSS v4, ~60 components |
| Tooling | pnpm workspaces, Turborepo, Biome, TypeScript strict |
apps/
web/ TanStack Start app → deployed as a Worker
api/ Hono Worker: /api/auth/* + your routes
packages/
auth/ Better Auth server + typed React client
db/ Drizzle schema (D1) + migrations
email/ React Email templates, renderer, theme
ui/ shadcn components, app sidebar, app header
types/ Shared CloudflareEnv binding types
The web Worker talks to the API Worker over a service binding (env.API), not the public internet — see session.ts. Browser-side auth calls go directly to the API origin via VITE_API_URL.
pnpm install1. Create the D1 database and paste the id into apps/api/wrangler.jsonc (it ships as REPLACE_ME):
npx wrangler d1 create example-db2. Configure secrets:
cp apps/api/.dev.vars.example apps/api/.dev.vars # then fill in BETTER_AUTH_SECRET
cp apps/web/.env.example apps/web/.env
openssl rand -base64 32 # value for BETTER_AUTH_SECRET3. Apply migrations to the local D1:
pnpm --filter @example/api migrate:local4. Run both apps:
pnpm dev # turbo runs web + api together- Web → http://localhost:3000
- API → http://localhost:8787
Run them individually with pnpm web / pnpm api.
Everything optional is off until its env var is set, so the starter boots with nothing but BETTER_AUTH_SECRET. All keys live in apps/api/.dev.vars.example and are typed in packages/types/src/env.ts.
| Variable | Effect when set |
|---|---|
BETTER_AUTH_SECRET |
Required. Signs sessions. |
GITHUB_CLIENT_ID / _SECRET |
Enables "Continue with GitHub" |
GOOGLE_CLIENT_ID / _SECRET |
Enables Google sign-in |
TURNSTILE_SECRET_KEY |
Captcha-gates sign-in/sign-up/reset (withCaptcha() client helper) |
POLAR_ACCESS_TOKEN |
Mounts checkout, portal, usage, webhooks |
ADMIN_USER_IDS |
Comma-separated ids that always resolve as admin |
COOKIE_DOMAIN |
Cross-subdomain session cookie (e.g. .example.com) |
EXTRA_TRUSTED_ORIGINS |
Required before an SSO IdP's discovery URL is accepted |
Social providers are wired in providers.ts; plugins in plugins.ts. Drop a block to remove a feature — nothing else references it.
Drizzle schema lives in packages/db/src/schema/ and mirrors Better Auth's generated schema exactly, including its singular table names (user, session, organization) with plural TS exports. Changing a table name will break the adapter.
pnpm --filter @example/db generate # schema change → new migration
pnpm --filter @example/api migrate:localApply to production with npx wrangler d1 migrations apply example-db --remote from apps/api.
(marketing)/ public landing
(auth)/ login, signup, forgot-password
(org)/$org/ authenticated shell — sidebar + header + org param
The $org layout route redirects to /login when there's no session, and carries the sidebar/nav/notification config in staticData — see route.tsx. That data is demo content; replace it with your own nav. conversations/ is an empty placeholder route.
Auth forms use react-hook-form + zod via the shared useAuthForm helper, which handles error surfacing and session invalidation after sign-in.
pnpm --filter @example/api deploy # deploy API first — web service-binds to it
pnpm --filter @example/web deployBefore the first deploy: set production secrets (npx wrangler secret put BETTER_AUTH_SECRET), point BETTER_AUTH_URL / WEB_URL / EMAIL_FROM in wrangler.jsonc at your real domains, and verify your sending domain for Cloudflare Email Routing.
| Command | Does |
|---|---|
pnpm dev |
Web + API together via Turborepo |
pnpm build |
Build every workspace |
pnpm typecheck |
tsc --noEmit across the monorepo |
pnpm lint |
Biome check |
pnpm format |
Biome check + write |
pnpm clean |
Remove all build output and node_modules |
Preview email templates with pnpm --filter @example/email preview (port 3001).
The starter ships under the @example/* scope with example as the app name. To rebrand, find-and-replace @example/ with your scope across package.json, tsconfig.json paths, and imports, then update name/database_name/service in both wrangler.jsonc files and the appName/issuer/rpName strings in packages/auth/src/.
Node ≥ 20, pnpm 10.