DEV Community

Cover image for How to deploy TanStack Start to Cloudflare Workers with D1, R2 and better-auth
Hongwei Wang
Hongwei Wang

Posted on

How to deploy TanStack Start to Cloudflare Workers with D1, R2 and better-auth

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 * * *"] }
}
Enter fullscreen mode Exit fullscreen mode

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(),
  ],
})
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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',
})
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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)),
])
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

(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),
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode
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 }
  })
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
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
}
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 types after every change to wrangler.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() and getEnv() are lazy, so the build never touches a binding.
  • Use db.batch() instead of db.transaction() on D1.
  • tanstackStartCookies() comes last, and ipAddressHeaders starts with cf-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)