TL;DR: Deploying a TanStack Start application to Cloudflare Workers requires careful configuration of
wrangler.jsonc, environment variable management across dev and production environments, D1 database setup with automated migrations, R2 object storage integration, and a solid CI/CD pipeline. This guide walks through every step of the process — from initial scaffolding to production monitoring — so you can ship with confidence on Cloudflare's edge network.
Introduction: Why Cloudflare Workers for TanStack Start
TanStack Start is a full-stack React framework built on Vinxi that brings server-side rendering, streaming, and static generation to the TanStack ecosystem. When it comes to deployment, Cloudflare Workers offer a uniquely compelling runtime for Start applications.
Unlike traditional Node.js servers that require cold starts, region pinning, and manual scaling, Cloudflare Workers run on V8 isolates across 330+ locations worldwide. This means your TanStack Start app can serve initial HTML, stream content updates, and handle API requests — all from the edge, close to every user.
But with this architectural shift comes a new set of configuration challenges. The Workers runtime doesn't support Node.js APIs like fs, net, or child_process. Your build pipeline, environment variables, and database connections all need to be adapted. This guide covers the production-ready setup we use to run TanStack Start on Cloudflare Workers at scale.
wrangler.jsonc Deep Configuration
The wrangler.jsonc file is the single source of truth for your Cloudflare deployment. Here is the production configuration we use, annotated with explanations:
{
"$schema": "https://raw.githubusercontent.com/cloudflare/workers-sdk/main/packages/wrangler/config-schema.json",
"name": "tanstack-ship",
"main": "./dist/worker.js",
"compatibility_date": "2026-05-01",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"directory": "./dist/public",
"binding": "ASSETS",
"html_handling": "auto",
"not_found_handling": "404"
},
"workers_dev": false,
"route": {
"pattern": "tanstack-ship.com",
"custom_domain": true,
"zone_id": "YOUR_ZONE_ID"
},
"d1_databases": [
{
"binding": "DB",
"database_name": "tanstack-ship-db",
"database_id": "YOUR_D1_DATABASE_ID",
"migrations_dir": "./migrations"
}
],
"r2_buckets": [
{
"binding": "R2",
"bucket_name": "tanstack-ship-assets"
}
],
"vars": {
"APP_VERSION": "1.0.0",
"SENTRY_DSN": ""
},
"env": {
"production": {
"vars": {
"SENTRY_DSN": "https://your-sentry-dsn@o123.ingest.sentry.io/456"
},
"routes": [
{
"pattern": "tanstack-ship.com",
"custom_domain": true
}
]
},
"preview": {
"workers_dev": true,
"vars": {
"SENTRY_DSN": ""
}
}
},
"observability": {
"enabled": true,
"head_sampling_rate": 0.1
}
}
Key Fields Explained
compatibility_flags: Thenodejs_compatflag enables a subset of Node.js APIs (e.g.,Buffer,process.env). Many npm packages depend on these, so keep this flag enabled.assets: Thehtml_handling: "auto"instructs Workers to serveindex.htmlfor directory requests — essential for client-side routing fallback. Thenot_found_handling: "404"ensures unknown paths return a proper 404.envblocks: Define environment-specific overrides. Thepreviewenvironment usesworkers_dev: truefor*.workers.devsubdomain, whileproductionbinds to a custom domain.
Environment Variables: Dev vs. Production
Local Development
Create a .dev.vars file in your project root:
# .dev.vars — loaded by wrangler dev
SENTRY_DSN=
STRIPE_SECRET_KEY=your-stripe-secret-key
RESEND_API_KEY=your-resend-api-key
PUBLIC_SITE_URL=http://localhost:5173
Cloudflare's wrangler dev command automatically reads .dev.vars and injects them into the Worker runtime.
Production Secrets
For production secrets, use wrangler secret put to keep sensitive values out of your config files:
npx wrangler secret put SENTRY_DSN
npx wrangler secret put STRIPE_SECRET_KEY
Secrets are encrypted at rest and injected at runtime on the edge. They are not accessible in the vars section of wrangler.jsonc — a common security mistake.
Type-Safe Access Pattern
Centralize all environment variable access in a single module with Zod validation for runtime safety:
// src/env.ts
import { z } from "zod";
const envSchema = z.object({
SENTRY_DSN: z.string().optional(),
STRIPE_SECRET_KEY: z.string().min(1),
RESEND_API_KEY: z.string().min(1),
PUBLIC_SITE_URL: z.string().url().default("http://localhost:5173"),
DB: z.any(), // D1 binding
R2: z.any(), // R2 binding
ASSETS: z.any(), // Assets binding
});
export type Env = z.infer<typeof envSchema>;
export function parseEnv(env: Record<string, unknown>): Env {
const result = envSchema.safeParse(env);
if (!result.success) {
console.error("Invalid environment variables:", result.error.flatten());
throw new Error("Environment validation failed");
}
return result.data;
}
This pattern gives you runtime type safety and catches misconfigured environments early in CI.
D1 Database Setup and Migrations
1. Create the Database
npx wrangler d1 create tanstack-ship-db
Copy the returned database_id into your wrangler.jsonc.
2. Write Migrations
Place SQL migration files in a migrations/ directory:
-- migrations/0001_users.sql
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_users_email ON users(email);
3. Apply Migrations
Locally:
npx wrangler d1 migrations apply tanstack-ship-db --local
In production:
npx wrangler d1 migrations apply tanstack-ship-db --remote
4. Automated Migrations in CI
# .github/workflows/deploy.yml (excerpt)
- name: Apply D1 Migrations
run: npx wrangler d1 migrations apply tanstack-ship-db --remote
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
Run migrations before deploying the new Worker version for zero-downtime deployments.
R2 Object Storage Integration
Add an R2 binding in wrangler.jsonc, then configure the client for file uploads and retrieval:
// src/lib/r2-client.ts
import type { R2Bucket } from "@cloudflare/workers-types";
let bucket: R2Bucket;
export function initR2(r2: R2Bucket) {
bucket = r2;
}
export async function uploadFile(
key: string,
body: ReadableStream | ArrayBuffer,
contentType: string
) {
return bucket.put(key, body, {
httpMetadata: { contentType },
customMetadata: { uploadedAt: new Date().toISOString() },
});
}
export async function getFile(key: string) {
const object = await bucket.get(key);
if (!object) return null;
return object;
}
Production Optimizations
Caching Headers
function setCachingHeaders(response: Response, pathname: string): Response {
const headers = new Headers(response.headers);
// Static assets: long-term cache
if (/\.(js|css|png|jpg|webp|svg|woff2?)$/i.test(pathname)) {
headers.set("Cache-Control", "public, max-age=31536000, immutable");
}
// HTML pages: short CDN cache, no browser cache
else if (pathname.endsWith(".html") || !pathname.includes(".")) {
headers.set("Cache-Control", "public, s-maxage=60, stale-while-revalidate=600");
}
return new Response(response.body, {
status: response.status,
headers,
});
}
Enable Streaming
// wrangler.jsonc
{
"compatibility_flags": ["nodejs_compat"],
"streaming": true
}
Streaming allows Suspense boundaries to send content progressively, improving perceived performance.
CI/CD Pipeline Setup
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: npm ci
- run: npm run typecheck
- run: npm run lint
- run: npm run build
env:
PUBLIC_SITE_URL: ${{ vars.PUBLIC_SITE_URL }}
- name: Apply D1 Migrations
run: npx wrangler d1 migrations apply tanstack-ship-db --remote
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- name: Deploy to Cloudflare Workers
run: npx wrangler deploy --env production
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
Common Deployment Issues and Solutions
-
"Module not found" for Node.js built-ins: Enable
nodejs_compatflag. If the module still fails, find a Workers-compatible alternative. -
D1 migration fails with "Table already exists": Always use
CREATE TABLE IF NOT EXISTSandCREATE INDEX IF NOT EXISTS. -
Client-side routing returns 404 on refresh: Ensure
html_handling: "auto"is set inwrangler.jsonc. -
Environment variables are undefined at runtime: Move secrets to
wrangler secret putand only usevarsfor non-sensitive configuration. -
Large bundle exceeds 1 MB limit: Ensure your Vite build tree-shakes unused code. Use dynamic
import()for heavy dependencies.
Conclusion
Deploying TanStack Start on Cloudflare Workers is one of the most performant and cost-effective ways to ship a full-stack React application. The edge runtime gives you global distribution, instant cold starts, and a generous free tier.
The key takeaways are:
- Validate your environment variables with Zod at the edge before any logic runs.
- Run migrations before deployments, not after.
- Set caching headers aggressively but thoughtfully — HTML gets
s-maxage, assets getimmutable. - Enable observability early — you will need those traces when something goes wrong.
For a hands-on walkthrough of building a complete application, see the TanStack Start End-to-End Tutorial. To take your D1 database to production, read Edge Database: Cloudflare D1 in Production. For official references, consult the Wrangler CLI, Cloudflare D1, and R2 documentation.