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, /docs redirects 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:

  1. Query strings. /blog?category=specs writes the same blog/index.html file 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.
  2. 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 in plugins. It post-processes the Set-Cookie headers that the other plugins produce.
  • Client IP. Set ipAddressHeaders to cf-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 small auth.cli-config.ts with the same plugins and fields but no bindings, and runs bunx @better-auth/cli generate against it. Then drizzle-kit generate turns 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 ExportedH...
Source:https://dev.to/hongwei_wang/how-to-deploy-tanstack-start-to-cloudflare-workers-with-d1-r2-and-better-auth-3hoc