Skip to content
Gary Wu
Go back

Deploying TanStack Start to Cloudflare Workers

Edit page

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:


  1. Why TanStack Start on Workers
  2. The Problem: Error 10021
  3. Root Cause Analysis
  4. The Solution: Architectural Pattern
  5. Configuration Walkthrough
  6. Accessing Cloudflare Bindings
  7. Server Functions with D1
  8. Durable Objects Integration
  9. Small Examples
  10. Comparison with Other Frameworks
  11. Anti-Patterns
  12. Before and After: The Full Picture
  13. Deployment Checklist
  14. Troubleshooting
  15. Advanced Patterns
  16. 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:

  1. Imports the TanStack Start server entry (which handles SSR, routing, and server functions)
  2. Re-exports it as the default fetch handler
  3. 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:

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"
  }
}

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 Env interface by hand. Run cf-typegen and let Wrangler generate it from your wrangler.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)). The env object 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 };
  });

FeatureTanStack StartNext.jsRemix / React RouterAstro
SSR modelFull-document SSR + hydrationRSC + streamingFull-document SSR + hydrationIslands architecture
Server functionscreateServerFn (type-safe)Server Actions (RSC)loader / actionAPI routes
RoutingType-safe file-basedFile-basedFile-basedFile-based
Workers supportOfficial (Cloudflare partner)Partial (@opennextjs/cloudflare)Official (@react-router/cloudflare)Official (@astrojs/cloudflare)
D1 accessimport { env } from "cloudflare:workers"Via adapter, undocumentedcontext.cloudflare.envAstro.locals.runtime.env
Durable ObjectsExport from worker.tsNot supported on WorkersExport from entry.serverVia custom integration
Build toolVite (native)Turbopack/WebpackVite (native)Vite (native)
Bundle size (SSR)Small (no RSC runtime)Large (RSC + streaming)SmallSmallest (islands)
Type safetyEnd-to-end (router + fns)PartialLoader types onlyNone built-in
Vendor lock-inNoneHigh (Vercel optimized)LowNone
Maturity on WorkersGA since late 2025Community adapterGA since 2025GA since 2024

When to choose TanStack Start on Workers

When NOT to choose TanStack Start


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:

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:

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 NotDo InsteadWhy
Initialize DB clients at module levelWrap in a function: getDb()env is not available during module init. You get undefined bindings and cryptic errors.
Put heavy validation schemas in global scopeDefine schemas in the file where they are used, or lazy-loadLarge Zod schemas with transforms can exceed the 1s startup limit.
Import @tanstack/react-form in server codeKeep form imports client-side onlyFormEventClient initializes in global scope and can trigger error 10021.
Use process.env for Cloudflare secretsUse 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 middlewareKeep worker.ts as a thin passthroughAll request-level logic belongs in TanStack Start server functions or middleware, not in the Worker entrypoint.
Use wrangler.tomlUse wrangler.jsoncCloudflare recommends JSONC as of Wrangler v3.91.0. It supports comments and has better editor support.
Forget nodejs_compat flagAlways include itTanStack Start dependencies use Node.js APIs. Without this flag, you get runtime errors about missing Buffer, crypto, etc.
Set main to dist/server/index.jsSet main to src/worker.ts or @tanstack/react-start/server-entryThe Cloudflare Vite plugin handles the build output. Pointing main at dist creates a double-build problem.
Deploy without running vite preview firstAlways preview locally before deployingPreview runs your built output in miniflare, which catches most runtime errors before they hit production.
Use app.config.ts with Nitro presetsUse vite.config.ts with @cloudflare/vite-pluginThe 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

Before every deploy

Deploy

pnpm run deploy

After deploy


Error 10021: Script startup exceeded CPU time limit

Cause: Too much work in global scope.

Debug:

  1. Check what is imported at the top level of your worker.ts and server functions
  2. Look for new SomeClient() or z.object({...}) at module level
  3. 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:

  1. Install @cloudflare/vite-plugin
  2. Ensure the cloudflare plugin is in your vite.config.ts
  3. 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:

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:

  1. Keep worker.ts thin. Re-export the TanStack Start entry and your DO classes. Nothing else.
  2. Never initialize at module level. Wrap everything in functions. The env object is not available during startup.
  3. Use cloudflare:workers for bindings. Not process.env, not request context hacking.
  4. Always preview before deploying. vite preview catches 90% of Workers-specific issues.
  5. 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

Changelog and Announcements

Community Resources

Known Issues

Libraries


Edit page
Share this post on:

Previous Post
Self-Healing Parsers
Next Post
Programmable 3D Avatar Faces