Org Status: 🟢 Active Cloudflare: N/A Last Audited: 2026-04-28
TanStack Start is a full-stack React framework built on Vite with file-based routing, server-side rendering, and type-safe server functions. Cloudflare Workers run your code on V8 isolates across 300+ edge locations worldwide. Together, they give you a production-grade React application that renders at the edge with sub-50ms cold starts — no containers, no regions, no origin servers.
This guide covers everything you need to deploy TanStack Start to Cloudflare Workers correctly, including the pitfalls that will waste your afternoon if you hit them unprepared.
What you will learn:
- How to configure TanStack Start for Cloudflare Workers from scratch
- Why your deploy fails with error 10021 and how to fix it permanently
- The correct
worker.tsentrypoint pattern (it is 5 lines, not 50) - How to access D1 databases and KV namespaces from server functions
- How to export Durable Objects alongside your TanStack Start app
- A deployment checklist you can follow for every project
- Why TanStack Start on Workers
- The Problem: Error 10021
- Root Cause Analysis
- The Solution: Architectural Pattern
- Configuration Walkthrough
- Accessing Cloudflare Bindings
- Server Functions with D1
- Durable Objects Integration
- Small Examples
- Comparison with Other Frameworks
- Anti-Patterns
- Before and After: The Full Picture
- Deployment Checklist
- Troubleshooting
- Advanced Patterns
- References
TanStack Start is the full-stack layer built on top of TanStack Router, the type-safe routing library used by thousands of React applications. Where Next.js leans into React Server Components and Vercel infrastructure, TanStack Start takes a different path: full-document SSR with hydration, explicit server functions via createServerFn, and a deploy-anywhere architecture powered by Nitro and Vite.
Cloudflare Workers are the natural deployment target for this architecture. Here is why:
Edge-native SSR. Your React application renders on the nearest Cloudflare PoP to the user. A visitor in Tokyo gets SSR from Tokyo, not from us-east-1. First Byte times drop from 200-400ms to 20-50ms.
V8 isolates, not containers. Workers boot in under 5ms. There is no cold start problem. Your TanStack Start app is always warm because V8 isolates are always ready.
Unified platform. D1 (SQLite at the edge), KV (global key-value), R2 (object storage), Durable Objects (stateful coordination), Queues, and AI — all accessible from the same Worker. Your TanStack Start server functions can talk to all of them without leaving Cloudflare’s network.
No vendor lock-in on the framework side. TanStack Start does not use platform-specific primitives in its routing or data loading. If you ever need to move off Workers, you change the Vite plugin and the Nitro preset. Your routes, loaders, and server functions stay the same.
Key insight: TanStack Start treats the deployment target as a plugin, not a foundation. This is the opposite of Next.js, where Vercel-specific optimizations are baked into the framework’s core behavior.
You have your TanStack Start app running locally with vite dev. Everything works. You run wrangler deploy and get this:
Error: Something went wrong with the request to Cloudflare...
Script startup exceeded CPU time limit. [API code: 10021]
Or you see this in the Cloudflare dashboard:
Error 10021: Script startup timed out.
Your app works locally but fails on deploy. The build succeeds. The upload succeeds. But Cloudflare rejects the Worker because it takes too long to start.
This is the single most common failure mode when deploying any SSR framework to Cloudflare Workers, and TanStack Start is no exception.
Cloudflare Workers have a strict startup constraint: all top-level (global scope) code must complete within 1 second of CPU time and 128 MB of memory. This is not the request handler — this is everything that runs when the Worker is first loaded, before any request arrives.
Here is what happens during Worker startup:
1. V8 isolate is created
2. Your Worker script is loaded
3. All top-level imports are resolved and executed
4. All module-level code runs (global scope)
5. The Worker is "ready" — fetch handler is now callable
Steps 3 and 4 must complete within 1 second. If they do not, Cloudflare refuses to deploy the Worker (error 10021) or kills it at runtime.
What triggers the timeout
Large dependency trees in global scope. When you import a module at the top level, its entire initialization chain runs during startup. ORMs, validation libraries, and UI component libraries can be surprisingly heavy.
Eager initialization. Code like this runs during startup:
// BAD: This runs in global scope
const schema = z.object({
// 200 fields with transforms, refinements, and pipes
})
// BAD: This instantiates a client at module load
const db = new Database(process.env.DATABASE_URL)
// BAD: This parses a large config file
const config = JSON.parse(fs.readFileSync('./config.json', 'utf8'))
Framework overhead. TanStack Start, React, and their dependency trees add up. The framework itself is not the problem — it is well-optimized for bundling. The problem is what you add on top.
The TanStack Form trap. A known issue (TanStack/router#5214): importing @tanstack/react-form causes a new FormEventClient() to execute in global scope, which can push you over the startup limit.
Key insight: Error 10021 is not about your code being slow. It is about your code doing too much work before the first request arrives. The fix is always the same: defer initialization to request time.
The correct architecture for TanStack Start on Cloudflare Workers is a thin worker entrypoint that re-exports the TanStack Start server handler and any Durable Object classes. Nothing else.
The minimal worker.ts
// src/worker.ts — the entire file
import startEntry from "@tanstack/react-start/server-entry";
export default startEntry;
That is it. Five lines including the comment. This file does three things:
- Imports the TanStack Start server entry (which handles SSR, routing, and server functions)
- Re-exports it as the default fetch handler
- Nothing else
If you need Durable Objects, add named exports:
// src/worker.ts — with Durable Objects
import startEntry from "@tanstack/react-start/server-entry";
export default startEntry;
// Named exports for Durable Object classes
export { HudSocket } from "./server/hud-socket";
export { CompetitiveIntelligenceCoordinator } from "./server/competitive-intelligence-coordinator";
If you need additional Worker handlers (Queues, Cron Triggers, Email), wrap the entry:
// src/worker.ts — with additional handlers
import handler from "@tanstack/react-start/server-entry";
export { HudSocket } from "./server/hud-socket";
export default {
fetch: handler.fetch,
async queue(batch: MessageBatch, env: Env, ctx: ExecutionContext) {
for (const message of batch.messages) {
console.log("Processing:", message.body);
message.ack();
}
},
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
console.log("Cron triggered:", event.cron);
},
};
Key insight: The worker.ts file is a wiring file, not a logic file. It connects TanStack Start’s handler to the Workers runtime and exports your DO classes. All application logic lives in server functions and route handlers.
Three files control the deployment: vite.config.ts, wrangler.jsonc, and package.json. Here is each one, explained line by line.
vite.config.ts
import { defineConfig } from "vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import { cloudflare } from "@cloudflare/vite-plugin";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
// Cloudflare plugin MUST come first — it sets up the SSR environment
cloudflare({ viteEnvironment: { name: "ssr" } }),
// TanStack Start plugin handles routing, server functions, SSR
tanstackStart(),
// React plugin for JSX transform and fast refresh
react(),
],
});
Plugin order matters. The cloudflare() plugin must be listed before tanstackStart(). It configures the Vite SSR environment that TanStack Start will use. If you put it after, the SSR environment will not target the Workers runtime and your bindings will not work.
The viteEnvironment: { name: "ssr" } option tells the Cloudflare plugin which Vite environment to target. TanStack Start uses an environment named "ssr" for its server-side build. This is required — without it, the plugin does not know where to inject Workers-specific configuration.
wrangler.jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-tanstack-app",
"compatibility_date": "2026-03-24",
"compatibility_flags": ["nodejs_compat"],
"main": "src/worker.ts",
"observability": {
"enabled": true
}
}
Line by line:
$schema— Enables autocomplete and validation in your editor. Points to the local wrangler schema.name— Your Worker name on Cloudflare. This becomesmy-tanstack-app.your-subdomain.workers.dev.compatibility_date— Set this to today’s date. It pins the Workers runtime behavior to a specific version, preventing breaking changes from affecting your deployed Worker.compatibility_flags: ["nodejs_compat"]— Required. TanStack Start and its dependencies use Node.js APIs (Buffer, crypto, streams). This flag enables the Node.js compatibility layer in Workers.main— Points to your worker entrypoint. If you are using the default TanStack Start entry without custom handlers, you can set this to"@tanstack/react-start/server-entry"instead.observability.enabled— Turns on Workers Logs, which you will need for debugging server functions.
If you use the default entry (no Durable Objects, no Queues):
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-tanstack-app",
"compatibility_date": "2026-03-24",
"compatibility_flags": ["nodejs_compat"],
"main": "@tanstack/react-start/server-entry",
"observability": {
"enabled": true
}
}
You do not even need a worker.ts file. TanStack Start provides a ready-made entry point that Wrangler can use directly.
package.json scripts
{
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"deploy": "npm run build && wrangler deploy",
"cf-typegen": "wrangler types"
}
}
dev— Starts the Vite dev server with the Cloudflare plugin. Your server functions run in miniflare (the local Workers simulator), so bindings like D1 and KV work locally.build— Produces the production bundle indist/.preview— Runs the built output in a local Workers environment. This is the closest thing to production without deploying.deploy— Builds and deploys in one step.cf-typegen— Generates TypeScript types for your Cloudflare bindings (D1, KV, Durable Objects, etc.) based on your wrangler.jsonc.
Required dependencies
pnpm add @tanstack/react-start @tanstack/react-router react react-dom
pnpm add -D @cloudflare/vite-plugin wrangler vite @vitejs/plugin-react
Cloudflare bindings (D1, KV, R2, AI, Durable Objects, environment variables) are accessed differently depending on where you are in the code.
In server functions: cloudflare:workers
TanStack Start provides access to the underlying platform request context through middleware and server function utilities. The recommended pattern for accessing Cloudflare bindings is through the cloudflare:workers module:
import { env } from "cloudflare:workers";
This import gives you typed access to all bindings defined in your wrangler.jsonc. It works in any server-side code — server functions, middleware, API routes.
Generating types for bindings
Run cf-typegen to create a worker-configuration.d.ts file with types for all your bindings:
pnpm run cf-typegen
This generates something like:
// worker-configuration.d.ts (auto-generated)
interface Env {
DB: D1Database;
KV_CACHE: KVNamespace;
HUD_SOCKET: DurableObjectNamespace;
COMPETITIVE_INTEL: DurableObjectNamespace;
API_MOM_KEY: string;
GITHUB_TOKEN: string;
}
Key insight: Never define your own
Envinterface by hand. Runcf-typegenand let Wrangler generate it from yourwrangler.jsonc. This keeps your types in sync with your actual bindings.
D1 is Cloudflare’s edge SQLite database. It is the natural choice for TanStack Start applications because it is co-located with your Worker — queries are fast and there is no connection pooling to manage.
Setting up D1
1. Create the database:
wrangler d1 create my-app-db
This outputs a database ID. Add it to wrangler.jsonc:
{
// ... other config
"d1_databases": [
{
"binding": "DB",
"database_name": "my-app-db",
"database_id": "your-database-id-here"
}
]
}
2. Create a migration:
-- migrations/001_init.sql
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
owner_id TEXT NOT NULL REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_projects_owner ON projects(owner_id);
CREATE INDEX idx_users_email ON users(email);
3. Apply the migration:
wrangler d1 execute my-app-db --local --file=migrations/001_init.sql
wrangler d1 execute my-app-db --remote --file=migrations/001_init.sql
Server function with D1
// src/server/users.ts
import { createServerFn } from "@tanstack/react-start";
import { env } from "cloudflare:workers";
export const getUsers = createServerFn().handler(async () => {
const db = env.DB;
const result = await db
.prepare("SELECT id, email, name, role, created_at FROM users ORDER BY created_at DESC")
.all<{
id: string;
email: string;
name: string;
role: string;
created_at: string;
}>();
return result.results;
});
export const createUser = createServerFn()
.validator((data: { email: string; name: string; role?: string }) => {
if (!data.email || !data.email.includes("@")) {
throw new Error("Invalid email");
}
if (!data.name || data.name.length < 2) {
throw new Error("Name must be at least 2 characters");
}
return data;
})
.handler(async ({ data }) => {
const db = env.DB;
const id = crypto.randomUUID();
await db
.prepare(
"INSERT INTO users (id, email, name, role) VALUES (?, ?, ?, ?)"
)
.bind(id, data.email, data.name, data.role ?? "viewer")
.run();
return { id, email: data.email, name: data.name, role: data.role ?? "viewer" };
});
Using server functions in a route
// src/routes/users.tsx
import { createFileRoute } from "@tanstack/react-router";
import { getUsers, createUser } from "../server/users";
export const Route = createFileRoute("/users")({
loader: () => getUsers(),
component: UsersPage,
});
function UsersPage() {
const users = Route.useLoaderData();
const handleCreateUser = async () => {
const newUser = await createUser({
data: { email: "jane@example.com", name: "Jane Doe", role: "editor" },
});
// Invalidate and refetch
window.location.reload();
};
return (
<div>
<h1>Users ({users.length})</h1>
<button onClick={handleCreateUser}>Add User</button>
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} ({user.email}) — {user.role}
</li>
))}
</ul>
</div>
);
}
Using Drizzle ORM with D1
For larger applications, raw SQL becomes unwieldy. Drizzle ORM is the best choice for D1 because it is lightweight, type-safe, and produces zero runtime overhead:
// src/lib/db/schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
name: text("name").notNull(),
role: text("role").notNull().default("viewer"),
createdAt: text("created_at").notNull().default("datetime('now')"),
updatedAt: text("updated_at").notNull().default("datetime('now')"),
});
export const projects = sqliteTable("projects", {
id: text("id").primaryKey(),
name: text("name").notNull(),
ownerId: text("owner_id")
.notNull()
.references(() => users.id),
status: text("status").notNull().default("active"),
createdAt: text("created_at").notNull().default("datetime('now')"),
});
// src/lib/db/index.ts
import { drizzle } from "drizzle-orm/d1";
import { env } from "cloudflare:workers";
import * as schema from "./schema";
// Lazy initialization — only creates the drizzle instance when called
export function getDb() {
return drizzle(env.DB, { schema });
}
// src/server/users.ts — with Drizzle
import { createServerFn } from "@tanstack/react-start";
import { eq } from "drizzle-orm";
import { getDb } from "../lib/db";
import { users } from "../lib/db/schema";
export const getUsers = createServerFn().handler(async () => {
const db = getDb();
return db.select().from(users).orderBy(users.createdAt);
});
export const getUserByEmail = createServerFn()
.validator((email: string) => {
if (!email.includes("@")) throw new Error("Invalid email");
return email;
})
.handler(async ({ data: email }) => {
const db = getDb();
const result = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
return result[0] ?? null;
});
Key insight: Notice the
getDb()function pattern. Do not create the Drizzle instance at module level (const db = drizzle(env.DB)). Theenvobject is not available during module initialization — it is only populated at request time. Wrap it in a function and call it lazily.
Durable Objects give you single-threaded, stateful compute with persistent storage. They are ideal for WebSocket servers, coordination logic, rate limiters, and any use case where you need strong consistency.
TanStack Start and Durable Objects coexist in the same Worker. Your worker.ts re-exports the DO classes, and Wrangler handles the rest.
Defining a Durable Object
// src/server/hud-socket.ts
export class HudSocket {
private state: DurableObjectState;
private sessions: Set<WebSocket> = new Set();
constructor(state: DurableObjectState) {
this.state = state;
// Restore hibernated WebSockets
for (const ws of this.state.getWebSockets()) {
this.sessions.add(ws);
}
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
// WebSocket upgrade
if (request.headers.get("Upgrade") === "websocket") {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.state.acceptWebSocket(server);
this.sessions.add(server);
return new Response(null, { status: 101, webSocket: client });
}
// POST /broadcast — push event to all clients
if (request.method === "POST" && url.pathname === "/broadcast") {
const body = await request.text();
this.broadcast(body);
return new Response("ok");
}
// GET /connections — diagnostic endpoint
if (url.pathname === "/connections") {
return Response.json({ connections: this.sessions.size });
}
return new Response("not found", { status: 404 });
}
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void {
if (typeof message !== "string") return;
try {
const data = JSON.parse(message) as { type: string };
if (data.type === "ping") {
ws.send(JSON.stringify({ type: "pong" }));
}
} catch {
// Ignore malformed messages
}
}
webSocketClose(ws: WebSocket): void {
this.sessions.delete(ws);
}
webSocketError(ws: WebSocket): void {
this.sessions.delete(ws);
}
private broadcast(message: string, exclude?: WebSocket): void {
for (const ws of this.sessions) {
if (ws === exclude) continue;
try {
ws.send(message);
} catch {
this.sessions.delete(ws);
}
}
}
}
Exporting from worker.ts
// src/worker.ts
import startEntry from "@tanstack/react-start/server-entry";
export default startEntry;
export { HudSocket } from "./server/hud-socket";
Configuring in wrangler.jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-tanstack-app",
"compatibility_date": "2026-03-24",
"compatibility_flags": ["nodejs_compat"],
"main": "src/worker.ts",
"durable_objects": {
"bindings": [
{
"name": "HUD_SOCKET",
"class_name": "HudSocket",
"script_name": "my-tanstack-app"
}
]
},
"migrations": [
{
"tag": "v1",
"new_classes": ["HudSocket"]
}
],
"d1_databases": [
{
"binding": "DB",
"database_name": "my-app-db",
"database_id": "your-database-id"
}
]
}
Accessing a Durable Object from a server function
// src/server/websocket.ts
import { createServerFn } from "@tanstack/react-start";
import { env } from "cloudflare:workers";
export const getConnectionCount = createServerFn().handler(async () => {
const id = env.HUD_SOCKET.idFromName("global");
const stub = env.HUD_SOCKET.get(id);
const response = await stub.fetch("https://do/connections");
const data = (await response.json()) as { connections: number };
return data;
});
export const broadcastMessage = createServerFn()
.validator((message: string) => {
if (!message) throw new Error("Message required");
return message;
})
.handler(async ({ data: message }) => {
const id = env.HUD_SOCKET.idFromName("global");
const stub = env.HUD_SOCKET.get(id);
await stub.fetch("https://do/broadcast", {
method: "POST",
body: JSON.stringify({ type: "notification", payload: message }),
});
return { sent: true };
});
Durable Object with alarm scheduling
For background jobs (daily SERP checks, report generation, cache warming), use DO alarms:
// src/server/daily-reporter.ts
export class DailyReporter {
private state: DurableObjectState;
private env: Env;
constructor(state: DurableObjectState, env: Env) {
this.state = state;
this.env = env;
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (request.method === "POST" && url.pathname === "/activate") {
// Schedule first alarm for next 6 AM UTC
const now = new Date();
const next6am = new Date(now);
next6am.setUTCHours(6, 0, 0, 0);
if (next6am <= now) {
next6am.setUTCDate(next6am.getUTCDate() + 1);
}
await this.state.storage.setAlarm(next6am.getTime());
return Response.json({ scheduled: next6am.toISOString() });
}
if (url.pathname === "/status") {
const nextAlarm = await this.state.storage.getAlarm();
return Response.json({
nextAlarm: nextAlarm ? new Date(nextAlarm).toISOString() : null,
});
}
return new Response("not found", { status: 404 });
}
async alarm(): Promise<void> {
// Do the daily work
try {
await this.generateDailyReport();
} catch (err) {
console.error("Daily report failed:", err);
}
// Schedule next alarm for tomorrow 6 AM UTC
const tomorrow = new Date();
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
tomorrow.setUTCHours(6, 0, 0, 0);
await this.state.storage.setAlarm(tomorrow.getTime());
}
private async generateDailyReport(): Promise<void> {
const db = this.env.DB;
const result = await db
.prepare("SELECT COUNT(*) as count FROM users WHERE created_at > datetime('now', '-1 day')")
.first<{ count: number }>();
console.log(`Daily report: ${result?.count ?? 0} new users in the last 24 hours`);
}
}
These are focused, self-contained patterns you will use repeatedly when building TanStack Start applications on Workers.
1. R2 file uploads via server function
// src/server/uploads.ts
import { createServerFn } from "@tanstack/react-start";
import { env } from "cloudflare:workers";
export const uploadFile = createServerFn()
.validator((data: { filename: string; contentType: string; body: string }) => {
if (!data.filename) throw new Error("Filename required");
if (!data.contentType) throw new Error("Content type required");
if (data.body.length > 10_000_000) throw new Error("File too large (max 10MB)");
return data;
})
.handler(async ({ data }) => {
const key = `uploads/${Date.now()}-${data.filename}`;
const bytes = Uint8Array.from(atob(data.body), (c) => c.charCodeAt(0));
await env.UPLOADS_BUCKET.put(key, bytes, {
httpMetadata: { contentType: data.contentType },
});
return {
key,
url: `https://your-public-bucket.r2.dev/${key}`,
size: bytes.length,
};
});
export const listUploads = createServerFn().handler(async () => {
const listed = await env.UPLOADS_BUCKET.list({ prefix: "uploads/" });
return listed.objects.map((obj) => ({
key: obj.key,
size: obj.size,
uploaded: obj.uploaded.toISOString(),
}));
});
Add to wrangler.jsonc:
{
"r2_buckets": [
{
"binding": "UPLOADS_BUCKET",
"bucket_name": "my-app-uploads"
}
]
}
2. Authentication middleware
TanStack Start middleware runs before server functions. Use it to validate auth tokens and inject user context:
// src/server/middleware.ts
import { createMiddleware } from "@tanstack/react-start";
import { env } from "cloudflare:workers";
type AuthContext = {
userId: string;
email: string;
role: "admin" | "editor" | "viewer";
};
export const authMiddleware = createMiddleware().server(async ({ next }) => {
const session = await validateSession();
if (!session) {
throw new Error("Unauthorized");
}
return next({
context: {
user: session satisfies AuthContext,
},
});
});
async function validateSession(): Promise<AuthContext | null> {
// Replace with your auth provider (Clerk, Auth.js, custom JWT)
// This shows the shape of the pattern
try {
// In practice, extract the token from cookies or headers
// via the request object from middleware context
return {
userId: "user_123",
email: "admin@example.com",
role: "admin",
};
} catch {
return null;
}
}
Using middleware in a protected server function:
// src/server/admin.ts
import { createServerFn } from "@tanstack/react-start";
import { authMiddleware } from "./middleware";
import { env } from "cloudflare:workers";
export const getAdminDashboard = createServerFn()
.middleware([authMiddleware])
.handler(async ({ context }) => {
if (context.user.role !== "admin") {
throw new Error("Admin access required");
}
const db = env.DB;
const [userCount, projectCount] = await Promise.all([
db.prepare("SELECT COUNT(*) as count FROM users").first<{ count: number }>(),
db.prepare("SELECT COUNT(*) as count FROM projects").first<{ count: number }>(),
]);
return {
admin: context.user.email,
stats: {
users: userCount?.count ?? 0,
projects: projectCount?.count ?? 0,
},
};
});
3. Queue consumer for background jobs
Process background work without blocking your server functions:
// src/server/queue-handler.ts
interface EmailJob {
to: string;
subject: string;
html: string;
}
export async function handleEmailQueue(
batch: MessageBatch<EmailJob>,
env: Env
): Promise<void> {
for (const message of batch.messages) {
const { to, subject, html } = message.body;
try {
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${env.RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "noreply@example.com",
to,
subject,
html,
}),
});
if (!response.ok) {
if (response.status >= 500) {
message.retry();
continue;
}
console.error(`Email send failed: ${response.status}`);
}
message.ack();
} catch (err) {
console.error("Email queue error:", err);
message.retry();
}
}
}
Wire it into worker.ts:
// src/worker.ts
import handler from "@tanstack/react-start/server-entry";
import { handleEmailQueue } from "./server/queue-handler";
export default {
fetch: handler.fetch,
async queue(batch: MessageBatch, env: Env, ctx: ExecutionContext) {
await handleEmailQueue(batch, env);
},
};
Enqueue from a server function:
// src/server/notifications.ts
import { createServerFn } from "@tanstack/react-start";
import { env } from "cloudflare:workers";
export const sendWelcomeEmail = createServerFn()
.validator((data: { email: string; name: string }) => data)
.handler(async ({ data }) => {
await env.EMAIL_QUEUE.send({
to: data.email,
subject: `Welcome, ${data.name}!`,
html: `<h1>Welcome to the platform, ${data.name}!</h1>
<p>Get started by creating your first project.</p>`,
});
return { queued: true };
});
Add to wrangler.jsonc:
{
"queues": {
"producers": [
{
"binding": "EMAIL_QUEUE",
"queue": "email-sends"
}
],
"consumers": [
{
"queue": "email-sends",
"max_batch_size": 10,
"max_retries": 3
}
]
}
}
4. Rate limiting with a Durable Object
A reusable rate limiter DO that any server function can call:
// src/server/rate-limiter.ts
export class RateLimiter {
private state: DurableObjectState;
private requests: Map<string, number[]> = new Map();
constructor(state: DurableObjectState) {
this.state = state;
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const key = url.searchParams.get("key") ?? "default";
const limit = Number.parseInt(url.searchParams.get("limit") ?? "60");
const windowMs = Number.parseInt(url.searchParams.get("window") ?? "60000");
const now = Date.now();
const timestamps = this.requests.get(key) ?? [];
const valid = timestamps.filter((t) => now - t < windowMs);
if (valid.length >= limit) {
return Response.json(
{
allowed: false,
remaining: 0,
retryAfter: Math.ceil((valid[0] + windowMs - now) / 1000),
},
{ status: 429 }
);
}
valid.push(now);
this.requests.set(key, valid);
return Response.json({
allowed: true,
remaining: limit - valid.length,
retryAfter: 0,
});
}
}
Using it from a server function:
// src/server/api-protected.ts
import { createServerFn } from "@tanstack/react-start";
import { env } from "cloudflare:workers";
async function checkRateLimit(key: string, limit = 30): Promise<void> {
const id = env.RATE_LIMITER.idFromName("global");
const stub = env.RATE_LIMITER.get(id);
const res = await stub.fetch(
`https://rate-limiter/?key=${encodeURIComponent(key)}&limit=${limit}&window=60000`
);
const data = (await res.json()) as { allowed: boolean; retryAfter: number };
if (!data.allowed) {
throw new Error(`Rate limit exceeded. Retry after ${data.retryAfter}s`);
}
}
export const searchApi = createServerFn()
.validator((query: string) => {
if (!query || query.length < 2) throw new Error("Query too short");
return query;
})
.handler(async ({ data: query }) => {
await checkRateLimit(`search:${query}`);
const db = env.DB;
const results = await db
.prepare("SELECT * FROM projects WHERE name LIKE ? LIMIT 20")
.bind(`%${query}%`)
.all();
return results.results;
});
5. Lazy-validated environment with Zod
Validate your environment at the edge of your application, not in global scope:
// src/lib/validated-env.ts
import { z } from "zod";
import { env as cloudflareEnv } from "cloudflare:workers";
const EnvSchema = z.object({
API_MOM_KEY: z.string().min(1, "API_MOM_KEY is required"),
GITHUB_TOKEN: z.string().min(1, "GITHUB_TOKEN is required"),
RESEND_API_KEY: z.string().optional(),
});
let validated: z.infer<typeof EnvSchema> | null = null;
export function getValidatedEnv() {
if (!validated) {
validated = EnvSchema.parse({
API_MOM_KEY: cloudflareEnv.API_MOM_KEY,
GITHUB_TOKEN: cloudflareEnv.GITHUB_TOKEN,
RESEND_API_KEY: cloudflareEnv.RESEND_API_KEY,
});
}
return validated;
}
Key insight: The Zod schema is defined at module level (that is fine — it is just an object). The validation runs lazily inside a function. This avoids the startup time cost of parsing while still giving you type-safe, validated env access.
6. Resilient data fetching with fallbacks
// src/server/resilient-data.ts
import { createServerFn } from "@tanstack/react-start";
import { env } from "cloudflare:workers";
export const getMetrics = createServerFn().handler(async () => {
try {
// Primary: fetch from D1
const db = env.DB;
const result = await db
.prepare(
"SELECT date, value FROM metrics WHERE date > datetime('now', '-30 days') ORDER BY date"
)
.all<{ date: string; value: number }>();
return { source: "d1" as const, data: result.results };
} catch (dbError) {
console.error("D1 query failed, falling back to KV:", dbError);
try {
// Fallback: cached data in KV
const cached = await env.KV_CACHE.get("metrics:last30d", "json");
if (cached) {
return { source: "kv-cache" as const, data: cached };
}
} catch (kvError) {
console.error("KV fallback also failed:", kvError);
}
// Last resort: empty state
return { source: "empty" as const, data: [] };
}
});
7. Custom 404 and error pages
// src/routes/__root.tsx
import {
createRootRoute,
Outlet,
ScrollRestoration,
} from "@tanstack/react-router";
import { Meta, Scripts } from "@tanstack/react-start";
export const Route = createRootRoute({
component: RootComponent,
notFoundComponent: NotFound,
errorComponent: ErrorPage,
});
function RootComponent() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
function NotFound() {
return (
<div style={{ textAlign: "center", padding: "4rem" }}>
<h1>404 -- Page Not Found</h1>
<p>The page you are looking for does not exist.</p>
<a href="/">Go home</a>
</div>
);
}
function ErrorPage({ error }: { error: Error }) {
return (
<div style={{ textAlign: "center", padding: "4rem" }}>
<h1>Something went wrong</h1>
<p>{error.message}</p>
<button type="button" onClick={() => window.location.reload()}>
Try again
</button>
</div>
);
}
8. KV-backed session store
// src/server/sessions.ts
import { createServerFn } from "@tanstack/react-start";
import { env } from "cloudflare:workers";
interface Session {
userId: string;
email: string;
createdAt: string;
expiresAt: string;
}
export const createSession = createServerFn()
.validator((data: { userId: string; email: string }) => data)
.handler(async ({ data }) => {
const sessionId = crypto.randomUUID();
const now = new Date();
const expires = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days
const session: Session = {
userId: data.userId,
email: data.email,
createdAt: now.toISOString(),
expiresAt: expires.toISOString(),
};
await env.KV_SESSIONS.put(
`session:${sessionId}`,
JSON.stringify(session),
{ expirationTtl: 7 * 24 * 60 * 60 } // Auto-expire in KV
);
return { sessionId, expiresAt: expires.toISOString() };
});
export const getSession = createServerFn()
.validator((sessionId: string) => {
if (!sessionId) throw new Error("Session ID required");
return sessionId;
})
.handler(async ({ data: sessionId }) => {
const raw = await env.KV_SESSIONS.get(`session:${sessionId}`, "json");
if (!raw) return null;
const session = raw as Session;
if (new Date(session.expiresAt) < new Date()) {
await env.KV_SESSIONS.delete(`session:${sessionId}`);
return null;
}
return session;
});
export const destroySession = createServerFn()
.validator((sessionId: string) => sessionId)
.handler(async ({ data: sessionId }) => {
await env.KV_SESSIONS.delete(`session:${sessionId}`);
return { destroyed: true };
});
| Feature | TanStack Start | Next.js | Remix / React Router | Astro |
|---|---|---|---|---|
| SSR model | Full-document SSR + hydration | RSC + streaming | Full-document SSR + hydration | Islands architecture |
| Server functions | createServerFn (type-safe) | Server Actions (RSC) | loader / action | API routes |
| Routing | Type-safe file-based | File-based | File-based | File-based |
| Workers support | Official (Cloudflare partner) | Partial (@opennextjs/cloudflare) | Official (@react-router/cloudflare) | Official (@astrojs/cloudflare) |
| D1 access | import { env } from "cloudflare:workers" | Via adapter, undocumented | context.cloudflare.env | Astro.locals.runtime.env |
| Durable Objects | Export from worker.ts | Not supported on Workers | Export from entry.server | Via custom integration |
| Build tool | Vite (native) | Turbopack/Webpack | Vite (native) | Vite (native) |
| Bundle size (SSR) | Small (no RSC runtime) | Large (RSC + streaming) | Small | Smallest (islands) |
| Type safety | End-to-end (router + fns) | Partial | Loader types only | None built-in |
| Vendor lock-in | None | High (Vercel optimized) | Low | None |
| Maturity on Workers | GA since late 2025 | Community adapter | GA since 2025 | GA since 2024 |
When to choose TanStack Start on Workers
- You want a React SPA with SSR and do not want RSC complexity
- You need type-safe server functions that are validated at the call site
- You are already using TanStack Router or TanStack Query
- You need Durable Objects alongside your web app (TanStack Start makes this easy)
- You want to avoid vendor lock-in while still getting edge performance
When NOT to choose TanStack Start
- You need content-focused sites with minimal JS — use Astro
- You need React Server Components — use Next.js
- You have an existing Remix app — stay on React Router 7
- You need maximum ecosystem maturity with Workers — React Router has the longest track record
Security is critical for edge deployments, but implementing it wrongly can trigger startup timeouts. Here are the patterns that work:
Key Principle: Keep worker.ts Clean
Your worker.ts should NEVER contain security logic, validation, or middleware initialization. All of it belongs in TanStack Start server functions, route handlers, and lazy-loaded middleware.
WRONG:
// src/worker.ts — BAD: Security checks in global scope
import { z } from "zod";
import rateLimit from "./lib/rate-limit";
// These run during startup and can trigger Error 10021
const validators = {
email: z.string().email(),
password: z.string().min(8),
};
const limiter = rateLimit({ maxRequests: 100, windowSeconds: 60 });
export default {
async fetch(request, env, ctx) {
// ... this approach kills startup time
}
};
RIGHT:
// src/worker.ts — GOOD: Just delegate to TanStack Start
import startEntry from "@tanstack/react-start/server-entry";
export default startEntry;
export { HudSocket } from "./server/hud-socket";
Authentication Middleware
Implement auth as TanStack Start middleware, not in worker.ts:
// src/server/auth-middleware.ts
import { createMiddleware } from "@tanstack/react-start";
import { env } from "cloudflare:workers";
export const requireAuth = createMiddleware().server(async ({ next, request }) => {
// Get auth context from request headers
const authHeader = request.headers.get("Authorization");
if (!authHeader) {
// Public routes bypass this middleware
return next();
}
const token = authHeader.replace("Bearer ", "");
const userId = await verifyToken(token, env.CLERK_SECRET_KEY);
if (!userId) {
throw new Response("Unauthorized", { status: 401 });
}
return next({
context: {
userId,
authToken: token,
},
});
});
async function verifyToken(token: string, secret: string): Promise<string | null> {
// Verify JWT or session token
// This runs per-request, not during startup
try {
const decoded = await verifyJWT(token, secret);
return decoded.sub;
} catch {
return null;
}
}
Apply to routes:
// src/routes/api/protected.ts
import { createAPIFileRoute } from "@tanstack/react-start/api";
import { requireAuth } from "../../server/auth-middleware";
export const Route = createAPIFileRoute("/api/protected")({
before: [requireAuth],
handlers: {
GET: async ({ context }) => {
const userId = context.userId; // Injected by middleware
return Response.json({ userId });
},
},
});
Input Validation
Define validators lazily, not at module level:
// src/lib/validation.ts — BAD: Do not export at module level
export const schemas = {
// Large Zod object definitions here
};
GOOD: Define validators in route handlers:
// src/routes/api/users.ts
import { createAPIFileRoute } from "@tanstack/react-start/api";
import { z } from "zod";
export const Route = createAPIFileRoute("/api/users").({
handlers: {
POST: async ({ request }) => {
// Define schema where it's used, not at module level
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
role: z.enum(["viewer", "editor", "admin"]),
});
const body = await request.json();
const result = createUserSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ error: "Validation failed", details: result.error.flatten() },
{ status: 400 }
);
}
// Use validated data
return Response.json({ created: result.data });
},
},
});
Shared validators via function:
// src/lib/validators.ts — GOOD: Return schema on demand
export function getUserSchema() {
return z.object({
email: z.string().email(),
name: z.string().min(1),
});
}
Rate Limiting
Implement rate limiting per-request, not in worker.ts:
// src/server/rate-limit.ts
const requestCounts = new Map<string, { count: number; resetAt: number }>();
export function checkRateLimit(
key: string,
maxRequests: number,
windowSeconds: number
): boolean {
const now = Date.now();
let entry = requestCounts.get(key);
if (!entry || now >= entry.resetAt) {
entry = { count: 1, resetAt: now + windowSeconds * 1000 };
requestCounts.set(key, entry);
return true;
}
entry.count++;
return entry.count <= maxRequests;
}
export function rateLimitResponse(retryAfterSeconds: number): Response {
return Response.json(
{ error: "Rate limit exceeded" },
{
status: 429,
headers: { "Retry-After": String(retryAfterSeconds) },
}
);
}
Use in route handlers:
// src/routes/api/login.ts
import { createAPIFileRoute } from "@tanstack/react-start/api";
import { checkRateLimit, rateLimitResponse } from "../../server/rate-limit";
export const Route = createAPIFileRoute("/api/login")({
handlers: {
POST: async ({ request }) => {
// Rate limit by IP
const ip = request.headers.get("CF-Connecting-IP") || "unknown";
if (!checkRateLimit(`login:${ip}`, 5, 60)) {
return rateLimitResponse(60);
}
// Handle login
return Response.json({ token: "..." });
},
},
});
Security Headers
Add headers in error boundaries or middleware, not in worker.ts:
// src/server/security-headers.ts
export function addSecurityHeaders(response: Response): Response {
const headers = new Headers(response.headers);
headers.set("X-Content-Type-Options", "nosniff");
headers.set("X-Frame-Options", "DENY");
headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
headers.set(
"Content-Security-Policy",
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'"
);
return new Response(response.body, {
status: response.status,
headers,
});
}
Use in TanStack Start error boundary:
// src/root.tsx
import { createRootRoute } from "@tanstack/react-router";
import { Outlet } from "@tanstack/react-router";
import { addSecurityHeaders } from "./server/security-headers";
export const Route = createRootRoute({
beforeLoad: async () => {
// Run-time security setup, not startup-time
},
component: () => <Outlet />,
});
Error Handling with Request IDs
Log errors with request IDs for debugging:
// src/routes/api/users.ts
import { createAPIFileRoute } from "@tanstack/react-start/api";
export const Route = createAPIFileRoute("/api/users")({
handlers: {
GET: async () => {
try {
// Handle request
} catch (error) {
const requestId = crypto.randomUUID();
console.error("Error in GET /api/users", {
requestId,
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
return Response.json(
{
error: "Internal Server Error",
requestId, // Send to client for debugging
},
{ status: 500 }
);
}
},
},
});
Global Security Context Pattern (Default-Deny Architecture)
For applications requiring consistent security across all routes, implement a global security context in the root route’s beforeLoad hook. This pattern:
- Default-deny: All routes require authentication by default
- Public whitelist: Only explicitly listed routes bypass authentication
- Per-request: No startup-time initialization (no Error 10021)
- Composable: Auth, rate limiting, headers applied uniformly
Step 1: Create global middleware
// src/server/global-middleware.ts
import { validateAuth } from "./auth-middleware";
import { checkRateLimit, rateLimitConfigs } from "./rate-limit-middleware";
import { addSecurityHeaders } from "./security-headers-middleware";
export const PUBLIC_ROUTES = [
"/health",
"/login",
"/sign-up",
"/sign-in",
"/landing",
"/logout",
];
export type GlobalSecurityContext = {
auth: ReturnType<typeof validateAuth>;
isPublic: boolean;
rateLimitRemaining: number;
requestId: string;
};
export function createSecurityContext(
request: Request
): { context: GlobalSecurityContext; shouldBlock?: Response } {
const pathname = new URL(request.url).pathname;
const isPublic = PUBLIC_ROUTES.some((route) =>
pathname === route || pathname.startsWith(route)
);
const auth = validateAuth(request);
const requestId = crypto.randomUUID();
// AUTHENTICATION: Check if route requires auth
if (!isPublic && !auth?.userId) {
const accept = request.headers.get("Accept") ?? "";
const response =
accept.includes("text/html") && request.method === "GET"
? new Response(null, {
status: 302,
headers: { Location: "/login" },
})
: new Response(JSON.stringify({ error: "Authentication required" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
return {
context: { auth, isPublic, rateLimitRemaining: 0, requestId },
shouldBlock: addSecurityHeaders(response),
};
}
// RATE LIMITING: Check if route is rate limited
let rateLimitRemaining = -1;
if (!isPublic && auth?.userId) {
const config = getRateLimitConfig(pathname);
const result = checkRateLimit(request, config, auth.userId);
if (!result.allowed) {
const resetInSeconds = Math.ceil((result.resetAt - Date.now()) / 1000);
const response = new Response(
JSON.stringify({
error: "Rate limit exceeded",
message: `Maximum ${config.maxRequests} requests per ${config.windowSeconds}s`,
retryAfter: resetInSeconds,
}),
{
status: 429,
headers: {
"Content-Type": "application/json",
"Retry-After": String(resetInSeconds),
"X-RateLimit-Limit": String(config.maxRequests),
"X-RateLimit-Remaining": String(result.remaining),
"X-RateLimit-Reset": String(Math.ceil(result.resetAt / 1000)),
},
}
);
return {
context: { auth, isPublic, rateLimitRemaining: result.remaining, requestId },
shouldBlock: addSecurityHeaders(response),
};
}
rateLimitRemaining = result.remaining;
}
return {
context: { auth, isPublic, rateLimitRemaining, requestId },
};
}
export function getRateLimitConfig(pathname: string) {
if (pathname.startsWith("/auth")) {
return { maxRequests: 5, windowSeconds: 60 };
}
if (pathname.includes("search")) {
return { maxRequests: 30, windowSeconds: 60 };
}
if (pathname.startsWith("/export") || pathname.includes("download")) {
return { maxRequests: 20, windowSeconds: 300 };
}
return { maxRequests: 100, windowSeconds: 60 }; // Default API
}
Step 2: Apply in root route
// src/routes/__root.tsx
import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router";
import { createSecurityContext, GlobalSecurityContext } from "../server/global-middleware";
export const Route = createRootRoute({
beforeLoad: async ({ context, location }) => {
const { context: security, shouldBlock } = createSecurityContext(
new Request(location.href, {
method: "GET",
headers: context?.request?.headers || new Headers(),
})
);
if (shouldBlock) {
throw shouldBlock; // Browser: 302 redirect to /login. API: 401 JSON.
}
return { security };
},
head: () => ({
meta: [
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ title: "My App" },
],
}),
component: () => <Outlet />,
});
Step 3: Access security context in routes
// src/hooks/useSecurityContext.ts
import { useRouterContext } from "@tanstack/react-router";
import { GlobalSecurityContext } from "../server/global-middleware";
export function useSecurityContext(): GlobalSecurityContext {
const context = useRouterContext();
return context as GlobalSecurityContext;
}
export function useAuth() {
return useSecurityContext().auth;
}
Result: All routes automatically inherit the security policy. Adding a public route requires only one line:
// To make a route public:
export const PUBLIC_ROUTES = [
"/health",
"/login",
"/public-docs", // ← Just add here
];
Benefits:
- ✅ No per-route boilerplate
- ✅ Consistent security across the application
- ✅ Easy to audit: public routes are in one place
- ✅ No startup overhead (runs per-request, not during initialization)
- ✅ TypeScript-safe:
GlobalSecurityContextis available to all routes - ✅ Testable: Middleware logic is pure functions, not tangled with route handlers
Anti-Pattern: Security in worker.ts
// src/worker.ts — WRONG
import startEntry from "@tanstack/react-start/server-entry";
import { z } from "zod";
import rateLimit from "./lib/rate-limit";
// PROBLEM: Global scope initialization
const userSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
/* ... 50 more fields ... */
});
const limiter = rateLimit({ maxRequests: 100 });
// PROBLEM: Worker.ts with custom logic
export default {
async fetch(request, env, ctx) {
// Check auth
// Check rate limit
// Validate input
// ... 100 lines later ...
return startEntry.fetch(request, env, ctx);
},
};
Result: Error 10021 on deploy.
Pattern: Security in TanStack Start
// src/worker.ts — RIGHT (5 lines)
import startEntry from "@tanstack/react-start/server-entry";
export default startEntry;
export { HudSocket } from "./server/hud-socket";
// src/routes/api/users.ts — RIGHT (security in route)
export const Route = createAPIFileRoute("/api/users")({
before: [requireAuth, requireRateLimit],
handlers: {
POST: async ({ request, context }) => {
// Validation in handler
const result = getUserSchema().safeParse(await request.json());
if (!result.success) {
return Response.json({ error: "Invalid" }, { status: 400 });
}
// Business logic
},
},
});
| Do Not | Do Instead | Why |
|---|---|---|
| Initialize DB clients at module level | Wrap in a function: getDb() | env is not available during module init. You get undefined bindings and cryptic errors. |
| Put heavy validation schemas in global scope | Define schemas in the file where they are used, or lazy-load | Large Zod schemas with transforms can exceed the 1s startup limit. |
Import @tanstack/react-form in server code | Keep form imports client-side only | FormEventClient initializes in global scope and can trigger error 10021. |
Use process.env for Cloudflare secrets | Use import { env } from "cloudflare:workers" | process.env is partially shimmed but unreliable for bindings. It works for simple strings but not for D1, KV, or DO namespaces. |
Create a 100-line worker.ts with middleware | Keep worker.ts as a thin passthrough | All request-level logic belongs in TanStack Start server functions or middleware, not in the Worker entrypoint. |
Use wrangler.toml | Use wrangler.jsonc | Cloudflare recommends JSONC as of Wrangler v3.91.0. It supports comments and has better editor support. |
Forget nodejs_compat flag | Always include it | TanStack Start dependencies use Node.js APIs. Without this flag, you get runtime errors about missing Buffer, crypto, etc. |
Set main to dist/server/index.js | Set main to src/worker.ts or @tanstack/react-start/server-entry | The Cloudflare Vite plugin handles the build output. Pointing main at dist creates a double-build problem. |
Deploy without running vite preview first | Always preview locally before deploying | Preview runs your built output in miniflare, which catches most runtime errors before they hit production. |
Use app.config.ts with Nitro presets | Use vite.config.ts with @cloudflare/vite-plugin | The Cloudflare Vite plugin replaces the old Nitro-based deployment. Using both causes conflicts. |
To tie everything together, here is a side-by-side comparison of a naive deployment (that fails) versus the correct approach.
WRONG: The approach that triggers error 10021
// vite.config.ts — WRONG (missing cloudflare plugin)
import { defineConfig } from "vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [tanstackStart(), react()],
});
// src/worker.ts — WRONG (too much logic in entrypoint)
import { Hono } from "hono";
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./lib/db/schema";
import { z } from "zod";
// BAD: All of this runs in global scope
const app = new Hono();
const validationSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
preferences: z.object({
theme: z.enum(["light", "dark"]),
notifications: z.boolean(),
language: z.string(),
}),
});
// BAD: Trying to initialize DB at module level
const db = drizzle(process.env.DB as unknown as D1Database, { schema });
app.get("/api/users", async (c) => {
const users = await db.select().from(schema.users);
return c.json(users);
});
export default app;
name = "my-app"
main = "dist/server/index.js"
compatibility_date = "2024-01-01"
Result: Error 10021 on deploy. The Hono app, Drizzle initialization, and Zod schema all execute in global scope. The stale compatibility date misses important runtime fixes. The .toml format is deprecated in favor of .jsonc.
CORRECT: The approach that works
// vite.config.ts — CORRECT
import { defineConfig } from "vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import { cloudflare } from "@cloudflare/vite-plugin";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
cloudflare({ viteEnvironment: { name: "ssr" } }),
tanstackStart(),
react(),
],
});
// src/worker.ts — CORRECT (thin entrypoint)
import startEntry from "@tanstack/react-start/server-entry";
export default startEntry;
// src/server/users.ts — CORRECT (logic in server functions)
import { createServerFn } from "@tanstack/react-start";
import { drizzle } from "drizzle-orm/d1";
import { env } from "cloudflare:workers";
import * as schema from "../lib/db/schema";
function getDb() {
return drizzle(env.DB, { schema });
}
export const getUsers = createServerFn().handler(async () => {
const db = getDb();
return db.select().from(schema.users);
});
// wrangler.jsonc — CORRECT
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-app",
"compatibility_date": "2026-03-24",
"compatibility_flags": ["nodejs_compat"],
"main": "src/worker.ts",
"observability": {
"enabled": true
},
"d1_databases": [
{
"binding": "DB",
"database_name": "my-app-db",
"database_id": "your-id-here"
}
]
}
Result: Deploys successfully. All initialization happens at request time inside server functions. The worker.ts is 3 lines of wiring. The Cloudflare Vite plugin handles the build targeting. Bindings are typed and accessible.
Use this checklist every time you deploy a TanStack Start app to Cloudflare Workers.
Before first deploy
-
wrangler login— authenticate with Cloudflare -
wrangler whoami— verify correct account - Create D1 databases:
wrangler d1 create <name> - Set secrets:
wrangler secret put API_KEY - Run migrations:
wrangler d1 execute <db> --remote --file=migrations/001_init.sql - Verify
wrangler.jsonchas correct database IDs - Run
pnpm run cf-typegento generate binding types
Before every deploy
-
pnpm run build— verify build succeeds -
pnpm run preview— test locally in Workers environment - Check for global-scope initializations (grep for
new .*Client(,drizzle(,createClient(at top level) - Verify
compatibility_dateis recent - Verify
nodejs_compatflag is present - Check bundle size:
ls -la dist/server/(if > 5MB, investigate)
Deploy
pnpm run deploy
After deploy
- Test the live URL in a browser (SSR, client navigation, server functions)
- Check Workers Logs in the Cloudflare dashboard for errors
- Verify D1 queries work (hit a route that reads from the database)
- Test Durable Object connections (if applicable)
Error 10021: Script startup exceeded CPU time limit
Cause: Too much work in global scope.
Debug:
- Check what is imported at the top level of your
worker.tsand server functions - Look for
new SomeClient()orz.object({...})at module level - Check if any dependency runs initialization code on import
Fix: Move initialization into functions. Use lazy patterns:
// BEFORE (breaks)
import { drizzle } from "drizzle-orm/d1";
import { env } from "cloudflare:workers";
const db = drizzle(env.DB);
// AFTER (works)
import { drizzle } from "drizzle-orm/d1";
import { env } from "cloudflare:workers";
function getDb() {
return drizzle(env.DB);
}
“Cannot find module cloudflare:workers”
Cause: Missing @cloudflare/vite-plugin or incorrect Vite config.
Fix:
- Install
@cloudflare/vite-plugin - Ensure the cloudflare plugin is in your
vite.config.ts - Make sure
viteEnvironment: { name: "ssr" }is set
Server function returns undefined bindings
Cause: Using process.env instead of import { env } from "cloudflare:workers" for non-string bindings.
Fix: Use the cloudflare:workers import for D1, KV, DO namespace bindings. process.env only works for plain string environment variables.
Build succeeds but deploy fails with “no default export”
Cause: Your worker.ts does not have a default export, or the Cloudflare Vite plugin is not processing it.
Fix: Ensure worker.ts has export default startEntry or an object with a fetch method. Check that the main field in wrangler.jsonc points to the correct file.
Local dev works but preview fails
Cause: vite dev uses Node.js for server functions. vite preview uses miniflare (Workers runtime). APIs that exist in Node.js but not Workers will fail in preview.
Fix: Stick to Workers-compatible APIs. Common offenders:
fsmodule (use R2 or KV instead)net/dnsmodules (usefetchfor HTTP calls)- Global
setInterval(use DO alarms for recurring work)
Durable Object migration errors
Cause: You added a new DO class but forgot the migration tag.
Fix: Add a migration entry in wrangler.jsonc:
{
"migrations": [
{
"tag": "v1",
"new_classes": ["HudSocket"]
},
{
"tag": "v2",
"new_classes": ["DailyReporter"]
}
]
}
Each time you add or rename a DO class, add a new migration tag. Tags must be unique and ordered.
TanStack Start version incompatibility
Cause: TanStack Start 1.142.x introduced a breaking change where middleware using cloudflare:workers imports gets bundled into the client build (TanStack/router#6185).
Fix: Check for version-specific issues in the TanStack Router issues. Pin to a known-good version if needed. The Cloudflare team and TanStack team actively collaborate on fixes — most issues are resolved within a few minor releases.
Here is the recommended file structure for a TanStack Start app deployed to Cloudflare Workers:
my-tanstack-app/
├── src/
│ ├── worker.ts # Worker entrypoint (thin)
│ ├── routes/
│ │ ├── __root.tsx # Root layout
│ │ ├── index.tsx # Home page
│ │ └── users.tsx # Users page
│ ├── server/
│ │ ├── users.ts # Server functions for users
│ │ ├── middleware.ts # Auth and other middleware
│ │ ├── hud-socket.ts # Durable Object class
│ │ ├── rate-limiter.ts # Rate limiter DO
│ │ ├── daily-reporter.ts # DO with alarm scheduling
│ │ └── queue-handler.ts # Queue consumer logic
│ ├── lib/
│ │ ├── db/
│ │ │ ├── schema.ts # Drizzle schema
│ │ │ └── index.ts # DB connection helper
│ │ └── validated-env.ts # Lazy env validation
│ └── components/
│ └── ... # React components
├── migrations/
│ └── 001_init.sql # D1 migration
├── public/
│ └── ... # Static assets
├── vite.config.ts # Vite + Cloudflare + TanStack
├── wrangler.jsonc # Cloudflare configuration
├── package.json
├── tsconfig.json
└── worker-configuration.d.ts # Auto-generated binding types
Key principle: server functions in src/server/, Durable Objects also in src/server/, database helpers in src/lib/db/. The worker.ts file at src/worker.ts is just wiring.
Environment-specific configuration
Use wrangler environments for staging vs production:
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-tanstack-app",
"compatibility_date": "2026-03-24",
"compatibility_flags": ["nodejs_compat"],
"main": "src/worker.ts",
"d1_databases": [
{
"binding": "DB",
"database_name": "my-app-db-prod",
"database_id": "prod-id-here"
}
],
"env": {
"staging": {
"name": "my-tanstack-app-staging",
"d1_databases": [
{
"binding": "DB",
"database_name": "my-app-db-staging",
"database_id": "staging-id-here"
}
]
}
}
}
Deploy to staging:
pnpm run build && wrangler deploy --env staging
GitHub Actions deployment
name: Deploy to Cloudflare Workers
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
command: deploy
KV for caching
// src/server/cache.ts
import { createServerFn } from "@tanstack/react-start";
import { env } from "cloudflare:workers";
export const getCachedData = createServerFn()
.validator((key: string) => key)
.handler(async ({ data: key }) => {
// Check KV cache first
const cached = await env.KV_CACHE.get(key, "json");
if (cached) return cached;
// Fetch fresh data
const fresh = await fetchExpensiveData(key);
// Cache for 1 hour
await env.KV_CACHE.put(key, JSON.stringify(fresh), {
expirationTtl: 3600,
});
return fresh;
});
async function fetchExpensiveData(key: string): Promise<unknown> {
const res = await fetch(`https://api.example.com/data/${key}`);
return res.json();
}
Add to wrangler.jsonc:
{
"kv_namespaces": [
{
"binding": "KV_CACHE",
"id": "your-kv-namespace-id"
}
]
}
Static prerendering for marketing pages
If some routes do not need dynamic data, prerender them at build time:
// vite.config.ts
import { defineConfig } from "vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import { cloudflare } from "@cloudflare/vite-plugin";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
cloudflare({ viteEnvironment: { name: "ssr" } }),
tanstackStart({
prerender: {
enabled: true,
routes: ["/", "/pricing", "/about"],
},
}),
react(),
],
});
Warning: Prerendered routes do not have access to Cloudflare bindings at build time. Only prerender routes that use static data or no data at all.
Custom domain with Workers
After deploying, add a custom domain:
wrangler deploy
Or configure routes in wrangler.jsonc:
{
"routes": [
{
"pattern": "app.example.com/*",
"zone_name": "example.com"
}
]
}
Tail-based debugging
When your deployed Worker misbehaves, use wrangler tail to stream live logs:
wrangler tail
wrangler tail --status error
wrangler tail --search "D1 query failed"
wrangler tail --format json | jq '.logs[]'
This is the Workers equivalent of tail -f on a server log. Combined with observability.enabled: true in your wrangler.jsonc, you get full visibility into server function execution, errors, and performance.
TanStack Start on Cloudflare Workers is a production-ready combination in 2026. The framework handles SSR, routing, and server functions. The platform handles compute, storage, and global distribution. The integration between them is a Vite plugin and a 5-line entrypoint file.
The critical things to remember:
- Keep
worker.tsthin. Re-export the TanStack Start entry and your DO classes. Nothing else. - Never initialize at module level. Wrap everything in functions. The
envobject is not available during startup. - Use
cloudflare:workersfor bindings. Notprocess.env, not request context hacking. - Always preview before deploying.
vite previewcatches 90% of Workers-specific issues. - Use
wrangler.jsonc, not.toml. It is the recommended format.
The error 10021 trap catches everyone once. After reading this guide, it should not catch you at all.
Official Documentation
- TanStack Start on Cloudflare Workers — Cloudflare’s official framework guide with setup instructions
- TanStack Start Hosting Guide — TanStack’s deployment documentation covering Cloudflare and other platforms
- Cloudflare Vite Plugin — Documentation for
@cloudflare/vite-plugin - Cloudflare Vite Plugin: Get Started — Step-by-step setup for the Vite plugin
- Workers Errors and Exceptions — Error 10021 and other deployment errors explained
- Workers Limits — Startup time, memory, and other runtime limits
- Durable Objects Documentation — Stateful coordination on Workers
- D1 Documentation — Cloudflare’s edge SQLite database
- TanStack Start vs Next.js — Official comparison from TanStack
- TanStack Framework Comparison — Detailed feature matrix
- Vite Environments — How the Cloudflare Vite plugin uses Vite environments
Changelog and Announcements
- Build TanStack Start apps with the Cloudflare Vite plugin — Cloudflare changelog announcing TanStack Start support
- Why Cloudflare, Netlify, and Webflow are collaborating to support Astro and TanStack — Cloudflare blog on their partnership with TanStack
- TanStack Start Cloudflare Example — Official example repository
- The Cloudflare Vite Plugin is Generally Available — GA announcement for the Vite plugin
Community Resources
- timoconnellaus/tanstack-start-workers — Community template for TanStack Start on Workers
- Deploying TanStack Start to Cloudflare Workers with GitHub Actions — GitHub Actions workflow gist
- How to deploy TanStack Start to Cloudflare Workers — Step-by-step walkthrough
- TanStack Start on Cloudflare Workers: The Full-Stack Stack Worth Watching — Analysis and practical guide
- TanStack Start vs Next.js vs Remix: Which React Framework Should You Choose in 2025? — Framework comparison from Makers’ Den
- Nipsuli/tanstack-start-cloudflare-worker — Minimal example of TanStack Start on Workers
- backpine/tanstack-start-on-cloudflare — Another community reference implementation
Known Issues
- TanStack/router#6185 — TanStack Start 1.142.x breaks production builds with middleware using platform-specific imports
- TanStack/router#5214 — TanStack Form breaks Start + Cloudflare
- TanStack/router#5208 — Cannot find module
cloudflare:workerswith Vite Plugin - TanStack/router#4473 — Full support by
@cloudflare/vite-plugintracking issue - TanStack/router#3468 — Cloudflare env vars not passed to SSR
- cloudflare/workers-sdk#10969 — TanStack Start + Prisma edge + Cloudflare Worker fails
- Cloudflare Community: Error 10021 — Community discussion on startup timeout
Libraries
- Drizzle ORM — Lightweight TypeScript ORM, works great with D1
- Wrangler CLI — Cloudflare’s CLI for Worker development and deployment
- TanStack Router — Type-safe routing for React
- TanStack Query — Async state management for React