TanStack Start runs well on Cloudflare Workers. The official guides get you to "Hello world" quickly. The trouble starts when you add a database, file uploads and auth, because each of those touches the Workers runtime in a slightly different way.
This post walks through a setup I use in production: one Worker that serves prerendered marketing pages as static assets and everything else from server code, with D1 (SQLite) through Drizzle, R2 for uploads, and better-auth for passwordless sign-in. Every snippet comes from a free, MIT-licensed starter you can clone and read in full: tanstack-start-cloudflare-starter. Versions at the time of writing: @tanstack/react-start 1.168, @cloudflare/vite-plugin 1.40, wrangler 4.99, better-auth 1.6, drizzle-orm 0.45.
1. One Worker, configured in wrangler.jsonc
Everything lives in a single Worker. D1, R2 and the rate limiter are bindings: the runtime hands them to your code, so you have no connection strings and no S3 keys.
{
"name": "my-app",
"compatibility_date": "2025-09-02",
"compatibility_flags": ["nodejs_compat"],
"main": "src/server.ts",
// Prerendered pages live at <path>/index.html; serve them at <path>
// instead of redirecting to a trailing slash.
"assets": { "html_handling": "drop-trailing-slash" },
"observability": { "enabled": true, "head_sampling_rate": 1 },
"d1_databases": [
{
"binding": "DB",
"database_name": "my-app",
"database_id": "REPLACE_ME", // from `wrangler d1 create my-app`
"migrations_dir": "drizzle"
}
],
"r2_buckets": [{ "binding": "BUCKET", "bucket_name": "my-app-files" }],
"ratelimits": [
{
"name": "PUBLIC_RATE_LIMIT",
"namespace_id": "1001",
"simple": { "limit": 5, "period": 60 }
}
],
"triggers": { "crons": ["0 0 * * *"] }
}
Two settings are worth calling out:
-
"html_handling": "drop-trailing-slash". The prerenderer writes/docs/index.html. Without this setting,/docsredirects to/docs/, and your canonical URLs and your actual URLs no longer match. -
"migrations_dir": "drizzle". Drizzle Kit writes SQL migrations into./drizzle, and Wrangler applies them from the same folder. Drizzle never talks to D1 directly.
After every change to this file, run wrangler types (the repo's bun run cf-typegen script). It regenerates the Env type, so env.DB and env.BUCKET are typed.
2. The Vite config: Cloudflare plugin plus prerendering
// vite.config.ts (trimmed: MDX, Paraglide and devtools plugins omitted)
import { cloudflare } from '@cloudflare/vite-plugin'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import { isPrivatePath } from './src/config/private-paths'
export default defineConfig({
resolve: { tsconfigPaths: true },
// Local D1/R2 live in .wrangler/state. Watching it turns every database
// write into a full page reload.
server: { watch: { ignored: ['**/.wrangler/**'] } },
plugins: [
cloudflare({ viteEnvironment: { name: 'ssr' } }),
tanstackStart({
prerender: {
enabled: true,
crawlLinks: true,
filter: (page) => !page.path.includes('?') && !isPrivatePath(page.path),
},
}),
viteReact(),
],
})
cloudflare({ viteEnvironment: { name: 'ssr' } }) is the setup from Cloudflare's TanStack Start guide. It makes vite dev run your server code inside workerd, the same runtime as production, with a local D1 database and R2 bucket under .wrangler/state.
Gotcha: watching .wrangler/. If Vite watches that folder, every database write or upload triggers a full page reload. In practice, an upload closed the dialog it came from. One ignored line fixes it.
Gotcha: the prerender filter. With crawlLinks: true, the prerenderer follows every link it finds, and that causes two separate problems:
-
Query strings.
/blog?category=specswrites the sameblog/index.htmlfile as/blog, and whichever one the crawler reaches last wins. That bug shipped a blog index with no posts on it. Skip any path that contains?and filter on the client. -
Guarded pages. If a marketing page links to
/dashboard, the crawler prerenders it. A static copy of a guarded page is served without its guard.
The fix is one list of private path segments. It feeds both the prerender filter and the Disallow lines in robots.txt, so the two can't drift apart:
export const privatePathSegments = [
'login', 'register', 'dashboard', 'settings', 'admin', 'api', /* ... */
] as const
// The trailing (\/|$) matters: without it /api-keys is excluded only
// because it happens to start with "api".
export const privatePathPattern = new RegExp(
`^(\\/zh)?\\/(${privatePathSegments.join('|')})(\\/|$)`,
)
export const isPrivatePath = (path: string) => privatePathPattern.test(path)
3. Read bindings in exactly one file
On Workers you can import env as a module-level global (docs). That is much nicer than passing env through every function, but it has a sharp edge. If cloudflare:workers ends up in the client bundle, the Vite build breaks, and the error often points at an unrelated file.
So only one file in the codebase is allowed to import it:
// src/core/server/cf.ts
import { env } from 'cloudflare:workers'
// The ONLY file allowed to touch `cloudflare:workers`. Never import it from
// client code. Server functions and server routes only.
export function getBindings(): Env {
return env as Env
}
Everything else calls getBindings(). For unit tests, Vitest aliases cloudflare:workers to a small stand-in module, so test code never needs the real runtime.
4. D1 with Drizzle: create the client lazily
// src/core/db/index.ts
import { drizzle } from 'drizzle-orm/d1'
import { getBindings } from '../server/cf'
import * as schema from './schema'
let db: ReturnType<typeof createDb> | undefined
function createDb() {
return drizzle(getBindings().DB, { schema })
}
// Lazy so the D1 binding is only touched at request time, never during build.
export function getDb() {
db ??= createDb()
return db
}
The lazy getDb() matters because the build prerenders pages. Any module that touches a binding at import time can fail during vite build, long before a request exists. Auth gets the same treatment below.
Drizzle Kit runs in generate-only mode. You never point it at a database:
// drizzle.config.ts
export default defineConfig({
schema: './src/core/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
})
bun run db:generate # drizzle-kit generate → SQL in ./drizzle
bun run db:migrate:local # wrangler d1 migrations apply DB --local
bun run db:migrate:remote # wrangler d1 migrations apply DB --remote
Gotcha: D1 has no interactive transactions. db.transaction() throws at runtime. If several writes have to land together, use db.batch(). It sends multiple statements in one round trip and applies them atomically. This is how the starter closes an account. It marks the user deleted and kills every session in one step:
const db = getDb()
const now = new Date()
await db.batch([
db.update(user)
.set({ deletedAt: now, purgeRemindedAt: null, updatedAt: now })
.where(and(eq(user.id, me.id), isNull(user.deletedAt))),
db.delete(session).where(eq(session.userId, me.id)),
])
Batching doesn't help when you need to read, branch in JavaScript and then write, such as spending a balance. The answer there is to move the decision into the WHERE clause. I wrote that pattern up separately: D1 has no interactive transactions: how to spend a balance safely anyway.
5. Secrets: validated once, read lazily
D1 and R2 need no credentials, so the only secrets are things like the auth secret and OAuth keys. Locally they live in .dev.vars, which is gitignored. In production they are set with wrangler secret put. Both arrive on the same env object, so one Zod schema covers both environments:
// src/core/env.ts
const optional = z.preprocess((v) => (v === '' ? undefined : v), z.string().optional())
const envSchema = z.object({
APP_URL: z.url(),
BETTER_AUTH_SECRET: z.string().min(16),
GOOGLE_CLIENT_ID: optional,
GOOGLE_CLIENT_SECRET: optional,
GITHUB_CLIENT_ID: optional,
GITHUB_CLIENT_SECRET: optional,
RESEND_API_KEY: optional,
})
let cached: z.infer<typeof envSchema> | undefined
export function getEnv() {
if (!cached) {
const parsed = envSchema.safeParse(getBindings())
if (!parsed.success) {
throw new Error(`Missing or invalid environment secrets:\n${z.prettifyError(parsed.error)}`)
}
cached = parsed.data
}
return cached
}
The preprocess step treats KEY= (empty) in .dev.vars as "not set". Without it, an empty string passes z.string() and a half-configured provider looks configured. Env is read at boot, so restart the dev server after editing .dev.vars.
6. better-auth on Workers
The starter is passwordless. Emailed six-digit codes always work. Google (with One Tap) and GitHub show up only when both halves of their key pair are set. Here are the parts of the config that are specific to Workers:
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { emailOTP, oneTap } from 'better-auth/plugins'
import { tanstackStartCookies } from 'better-auth/tanstack-start'
function createAuth() {
const env = getEnv()
return betterAuth({
baseURL: env.APP_URL,
secret: env.BETTER_AUTH_SECRET,
// On Cloudflare only cf-connecting-ip is set by the edge; the default
// x-forwarded-for has a first hop the caller controls.
advanced: {
ipAddress: { ipAddressHeaders: ['cf-connecting-ip', 'x-forwarded-for'] },
},
database: drizzleAdapter(getDb(), { provider: 'sqlite', schema }),
socialProviders: {
...(googleEnabled() ? { google: { clientId: env.GOOGLE_CLIENT_ID!, clientSecret: env.GOOGLE_CLIENT_SECRET! } } : {}),
...(githubEnabled() ? { github: { clientId: env.GITHUB_CLIENT_ID!, clientSecret: env.GITHUB_CLIENT_SECRET! } } : {}),
},
session: { expiresIn: 60 * 60 * 24 * 7, updateAge: 60 * 60 * 24 },
plugins: [
oneTap(),
emailOTP({
otpLength: 6,
expiresIn: 10 * 60,
allowedAttempts: 3,
storeOTP: 'hashed',
sendVerificationOTP: async ({ email, otp, type }) => {
/* send with Resend; without a key, log the code for local dev */
},
}),
tanstackStartCookies(), // must stay LAST: it post-processes Set-Cookie
],
})
}
let auth: ReturnType<typeof createAuth> | undefined
export function getAuth() {
auth ??= createAuth() // lazy, same reason as getDb()
return auth
}
(I've trimmed user.additionalFields and the database hooks. The full file is in the repo at src/features/auth/server/auth.ts.)
Things that bite:
-
tanstackStartCookies()goes last inplugins. It post-processes theSet-Cookieheaders that the other plugins produce. -
Client IP. Set
ipAddressHeaderstocf-connecting-ip, or better-auth's rate limiter and your session rows record an IP the client chose. -
storeOTP: 'hashed'means the database never holds a usable sign-in code. -
The better-auth CLI runs in Node and can't resolve
cloudflare:workers. To generate the auth tables, the starter keeps a smallauth.cli-config.tswith the same plugins and fields but no bindings, and runsbunx @better-auth/cli generateagainst it. Thendrizzle-kit generateturns the result into a migration.
Mount the handler as a catch-all server route:
// src/routes/api/auth.$.ts
export const Route = createFileRoute('/api/auth/$')({
server: {
handlers: {
GET: ({ request }) => getAuth().handler(request),
POST: async ({ request }) =>
(await sendCodeThrottled(request)) ? tooManyRequests() : getAuth().handler(request),
},
},
})
Rate limiting where isolates can't
That sendCodeThrottled check exists because of how Workers run. better-auth's built-in limiter counts requests in memory. On Workers, that is one counter per isolate, and it resets whenever an isolate is recycled. Sending a code costs you email quota and lands in someone else's inbox, so the starter puts Cloudflare's Rate Limiting binding in front of it twice: once per IP, and once per email address. The per-address check matters because an attacker who rotates IPs is still mailing the same victim.
async function sendCodeThrottled(request: Request): Promise<boolean> {
if (!new URL(request.url).pathname.endsWith('/email-otp/send-verification-otp')) return false
const { headers } = request
if (await isRateLimited('otp-ip', { headers })) return true
const body = await request.clone().json<{ email?: unknown }>().catch(() => null)
const email = typeof body?.email === 'string' ? body.email.trim().toLowerCase() : ''
return email ? isRateLimited('otp-to', { headers, key: email }) : false
}
isRateLimited calls binding.limit({ key }) on the PUBLIC_RATE_LIMIT binding from wrangler.jsonc and fails open if the binding is missing. That way a config gap never takes sign-in down.
7. Authenticated server functions, and two CSRF holes to close
All of the auth boilerplate lives in one function middleware. Feature code starts from authedFn and receives a typed session:
// src/core/server/fn.ts
const authMiddleware = createMiddleware({ type: 'function' }).server(async ({ next }) => {
const session = await getAuth().api.getSession({ headers: getRequest().headers })
if (!session) throw redirect({ to: '/login' })
return next({ context: { session } })
})
export const authedFn = createServerFn({ method: 'POST' }).middleware([authMiddleware])
export const setMyLocaleFn = authedFn
.validator(z.object({ locale: z.string().refine(isLocale) }))
.handler(async ({ data, context }) => {
await getDb().update(user)
.set({ locale: data.locale, updatedAt: new Date() })
.where(eq(user.id, context.session.user.id))
return { success: true }
})
Gotcha 1: method: 'POST', even for reads. A GET server function runs straight from a ?payload= in the URL. The session cookie is SameSite=lax, and browsers still send lax cookies on a top-level navigation from another site. So with GET, any page could link a signed-in user straight into a state-changing function. POST costs you nothing for reads, because loaders call these functions directly during SSR.
Gotcha 2: refuse cross-site calls to /_serverFn/ entirely. This happens in the Worker entry, before TanStack decodes anything:
// src/server.ts
import handler from '@tanstack/react-start/server-entry'
export default {
async fetch(req) {
if (isCrossSiteServerFnCall(req)) {
return withSecurityHeaders(new Response('Cross-site request refused', { status: 403 }), req)
}
const res = await paraglideMiddleware(req, () => handler.fetch(req))
return withSecurityHeaders(res, req)
},
async scheduled(_controller, _env, ctx) {
ctx.waitUntil(runJobs()) // the cron trigger from wrangler.jsonc
},
} satisfies ExportedHandler<Env>
export function isCrossSiteServerFnCall(request: Request): boolean {
const url = new URL(request.url)
if (!url.pathname.startsWith('/_serverFn/')) return false
const site = request.headers.get('sec-fetch-site')
// `same-site` is refused too: a sibling subdomain is not this app.
if (site) return site !== 'same-origin' && site !== 'none'
const origin = request.headers.get('origin')
return origin !== null && origin !== url.origin
}
API routes are deliberately left out of this check. Webhooks and API clients come from other origins and authenticate without cookies.
Gotcha 3: what goes in fn.ts. That file is bundled for the client too, because server functions leave a stub behind. The build strips only the code that the .server() callbacks reference. If you add a plain exported helper that touches the database, it drags cloudflare:workers into the browser bundle.
8. R2 uploads from a server route
Avatars are one R2 object per user. The upload route checks the session first, then the rate limit, then the type and size. It streams the file straight into the bucket:
// src/routes/api/avatar.ts (trimmed)
POST: async ({ request }) => {
const session = await getAuth().api.getSession({ headers: request.headers })
if (!session) return new Response('Unauthorized', { status: 401 })
if (await isRateLimited('avatar', { headers: request.headers })) return tooManyRequests()
const file = (await request.formData()).get('file')
if (!(file instanceof File)) return new Response('Missing file', { status: 400 })
if (!AVATAR_TYPES.includes(file.type)) return new Response('Unsupported type', { status: 415 })
if (file.size > AVATAR_MAX_BYTES) return new Response('File too large', { status: 413 }) // 2 MB
await getBindings().BUCKET.put(`avatars/${session.user.id}`, file.stream(), {
httpMetadata: { contentType: file.type, cacheControl: 'public, max-age=31536000, immutable' },
})
const image = `/api/avatar/${session.user.id}?v=${Date.now()}`
await getDb().update(user).set({ image, updatedAt: new Date() }).where(eq(user.id, session.user.id))
return Response.json({ image })
}
The rate limit runs after the session check, so an anonymous flood can't use up a real user's quota. The stored URL carries a ?v= version, which is why a one-year immutable cache is safe. A new upload gets a new URL. Reading the file back is a BUCKET.get() that returns object.body with the stored content type.
9. Deploy
bunx wrangler login
bunx wrangler d1 create my-app # paste the id into wrangler.jsonc
bunx wrangler r2 bucket create my-app-files
bunx wrangler secret put BETTER_AUTH_SECRET # a NEW value, not your local one
bunx wrangler secret put APP_URL # https://your-domain.com
bun run db:migrate:remote
bun run deploy # vite build && wrangler deploy
For a custom domain, uncomment the routes entry in wrangler.jsonc ({ "pattern": "example.com", "custom_domain": true }). The zone has to be in the same Cloudflare account. Also add https://your-domain.com/api/auth/callback/google (and /github) as redirect URIs in your OAuth apps.
Gotcha: migrations and deploys are separate steps. The CI workflow in the starter never applies migrations, because a schema change should be a step you run and watch yourself. What it does do is refuse to deploy code whose migrations haven't been applied yet:
- name: Refuse to deploy over unapplied D1 migrations
run: |
out=$(bunx wrangler d1 migrations list DB --remote 2>&1)
echo "$out"
if ! grep -q "No migrations to apply" <<<"$out"; then
echo "::error::Unapplied D1 migrations — run 'bun run db:migrate:remote' locally, then re-run this deploy."
exit 1
fi
Another CI step runs drizzle-kit generate against the committed schema and fails if that writes anything. If it does, someone changed the schema without committing the migration.
Checklist
-
html_handling: "drop-trailing-slash", so prerendered pages serve at their canonical URL. - Run
wrangler typesafter every change towrangler.jsonc. - Keep
.wrangler/**out of Vite's watcher. - The prerender filter skips query strings and every guarded path, using the same list as
robots.txt. - Only one file imports
cloudflare:workers. -
getDb(),getAuth()andgetEnv()are lazy, so the build never touches a binding. - Use
db.batch()instead ofdb.transaction()on D1. -
tanstackStartCookies()comes last, andipAddressHeadersstarts withcf-connecting-ip. - Rate-limit email codes with the Cloudflare binding, both per IP and per address.
- Authenticated server functions use POST, and cross-site
/_serverFn/calls get a 403. - Apply migrations by hand, and have CI refuse to deploy over pending ones.
The code
Everything above is in tanstack-start-cloudflare-starter. It's free and MIT-licensed, and it also includes Paraglide i18n (English and Chinese), an MDX blog and docs, shadcn/ui, and Vitest and Playwright tests. Clone it, run bun install && bun run dev, and the emailed sign-in code prints in your terminal, so you don't need an email provider to try it.
It's the free edition of ShipKit, which I also build. ShipKit is the paid version on the same architecture and adds payments (Stripe or Creem), usage credits and an admin console. You don't need it for anything in this post.
If you've hit a different Workers gotcha with TanStack Start, I'd like to hear about it in the comments.
Top comments (0)