Full-Stack SSR on Cloudflare Workers: A Complete Guide

Complete guide to building and deploying a server-side rendered full-stack application on Cloudflare Workers. Covers streaming SSR, D1 database, R2 storage, Queues, Workflows, and production patterns from real deployments.

Huifer
Huifer
July 20, 20269 min read
Also available in:Deutsch · 中文

Written by Huifer, solo developer and maintainer of TanStack Ship. I have shipped 12+ production apps on Cloudflare Workers + TanStack Start between 2023 and 2026. The patterns in this guide are the ones that shipped — not the ones that looked best in demos. The architecture described here runs my own SaaS at roughly $14/month with sub-50ms cold starts. Where Workers has rough edges, I name them.

Verified sources: Cloudflare Workers Docs · D1 Docs · TanStack Start Docs · TanStack Ship GitHub Last updated: 2026-07-20 · Changelog


TL;DR: Cloudflare Workers runs JavaScript at the edge in V8 isolates — not containers. Combined with TanStack Start's streaming SSR, D1 for the database, R2 for files, and Queues + Workflows for background work, you get a full-stack SaaS architecture that is fast, cheap, and globally distributed. This guide walks through the entire stack: setup, streaming SSR, D1, R2, deployment, monitoring, and cost. Real numbers from a 12-module production deployment included.


Why Cloudflare Workers for SSR?

Three reasons that, together, justify the architecture.

1. Cold start is sub-50ms

Workers runs in V8 isolates, not containers. There is no container spin-up, no Node.js bootstrap, no module resolution dance. The first request after deploy hits the runtime and runs in tens of milliseconds. For SSR, where TTFB directly maps to user-perceived latency, this is the difference between "fast" and "instantly responsive."

2. The cost profile is dramatically lower

A modest SaaS on Workers runs $5–20/month at production load. The same app on a container-based host runs $50–200/month. The billing model is per-request, not per-idle-container, so cost scales with actual usage rather than provisioned capacity.

3. The platform is coherent

Workers, D1 (SQLite at the edge), R2 (S3-compatible storage), Queues (message queue), Workflows (durable execution), KV (key-value store), Durable Objects (stateful coordination) — they all live in the same dashboard, the same billing, the same deployment story. There is no "Workers app, RDS database, S3 bucket, SQS queue" sprawl. Everything is configured in wrangler.toml.

The trade-off: Workers has a 128MB memory limit per isolate, CPU-time limits on the free plan, and some Node.js APIs are unavailable. For a SaaS workload, none of these are show-stoppers — but you should know them going in. I will name the rough edges where they show up.

When NOT to Use Workers

Honesty matters here. Workers is not the right choice for every workload.

CPU-heavy synchronous work

Workers' CPU-time limit is 30 seconds on the paid plan, but the practical sweet spot is sub-50ms. If your route does CPU-heavy synchronous work (image processing, video transcoding, complex math simulations), Workers is the wrong runtime. Use a container-based host for those workloads.

Apps that depend on long-lived connections

Workers cannot hold a single connection open for hours. WebSocket-heavy applications — chat apps, real-time collaboration tools, live streaming — need Durable Objects or a different host. The constraint is fundamental to the V8 isolate model.

Apps that need a traditional relational database

D1 is SQLite, and SQLite's write throughput is constrained. If your app needs thousands of writes per second sustained, D1 is the wrong choice. For those workloads, point a Queue at an external Postgres (Neon, Supabase, RDS) and accept the architecture complexity.

Apps with large existing infrastructure

If you already have RDS, S3, SQS, ECS, and a year of IaC, migrating to Workers is a rewrite, not a refactor. Workers' value is highest for greenfield projects and apps willing to embrace the Workers-native primitives.

Setting Up a TanStack Start Project on Workers

The fastest path in 2026 is the TanStack Start CLI:

bash
# Create the project
npm create @tanstack/start@latest my-saas
cd my-saas

# Add Cloudflare Workers adapter
npm install @tanstack/start-cloudflare-workers

# Generate wrangler.toml scaffold
npx wrangler init

The CLI generates a wrangler.toml like this:

toml
name = "my-saas"
main = "./dist/server/index.js"
compatibility_date = "2026-07-01"
compatibility_flags = ["nodejs_compat"]

[[d1_databases]]
binding = "DB"
database_name = "my-saas-prod"
database_id = "your-database-id"

[[r2_buckets]]
binding = "STORAGE"
bucket_name = "my-saas-uploads"

[[queues.producers]]
binding = "BILLING_QUEUE"
queue = "billing-events"

[[kv_namespaces]]
binding = "CACHE"
id = "your-kv-id"

[vars]
APP_ENV = "production"

Each [[bindings]] block gives your Worker a typed binding — env.DB, env.STORAGE, etc. — that is accessible in Server Functions. The nodejs_compat flag opts into Node.js API polyfills for libraries that need them.

For a complete TanStack Start deployment walkthrough, see the TanStack Ship features page; for routing patterns, see the streaming SSR guide.

Streaming SSR: How It Works

Streaming SSR is the technique that lets the server send HTML to the browser as soon as each piece is ready, instead of waiting for the full page to render before sending any bytes.

In React 19, this is implemented via the <Suspense> boundary. Each Suspense boundary sends its content to the browser as soon as it resolves:

tsx
// app/dashboard/page.tsx
import { Suspense } from "react";
import { LoadingSkeleton } from "~/components/loading-skeleton";

export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>

      {/* Sends immediately */}
      <Suspense fallback={<LoadingSkeleton />}>
        <RecentOrders /> {/* Async Server Component — streams when ready */}
      </Suspense>

      {/* Sends immediately */}
      <Suspense fallback={<LoadingSkeleton />}>
        <MonthlyRevenue /> {/* Async Server Component — streams when ready */}
      </Suspense>
    </main>
  );
}

The browser receives the shell of the page immediately, then receives the recent orders and monthly revenue sections as each resolves. TTFB drops from "time to render the whole page" to "time to render the page shell."

The performance impact: in production, streaming SSR drops median TTFB from ~180ms to ~40ms on a typical SaaS dashboard. That is the difference between "feels fast" and "feels instant."

Where streaming helps most

Streaming helps most on pages with multiple async data sources. A login page that loads user info, billing status, and notifications streams all three in parallel rather than serially. A product detail page that loads product info, inventory, and reviews streams all three in parallel.

Streaming helps least on pages with a single data source. There is nothing to stream if there is only one piece of data to wait for.

Patterns that scale vs patterns that do not

Two patterns scale well on Workers; two do not.

Scales well: many small independent queries that resolve in parallel via Suspense. The framework fans out, isolates resolve concurrently, and the user sees a fast TTFB with content filling in.

Scales well: synchronous single-query routes that finish in 10–50ms. The request fits in a single CPU-time budget, no retries needed, no async coordination.

Does not scale well: a single route that fires 10 sequential queries and waits for each. The total time is the sum; the user waits.

Does not scale well: a route that loads a large dataset (megabytes) and serializes it in-memory before responding. The 128MB memory limit and CPU-time budget will get you. Paginate instead.

The rule: design routes to do bounded work, parallelize across Suspense boundaries, and stream as much as possible.

D1: The Database at the Edge

D1 is Cloudflare's distributed SQLite database. Reads replicate to every region; writes go to a primary region and replicate. For SaaS workloads under a few hundred writes per second per region, D1 is the cheapest, fastest database story available.

Schema and migrations

The standard pattern is to use Drizzle ORM with a migration workflow:

ts
// db/schema.ts
import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";

export const users = sqliteTable("users", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  email: text("email").notNull().unique(),
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
});

export const subscriptions = sqliteTable("subscriptions", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  userId: integer("user_id").references(() => users.id).notNull(),
  status: text("status").notNull(),
  stripeCustomerId: text("stripe_customer_id").notNull(),
});
bash
# Generate migration
npx drizzle-kit generate

# Apply locally
npx wrangler d1 migrations apply my-saas-prod --local

# Apply to production
npx wrangler d1 migrations apply my-saas-prod --remote

The write-lock constraint

D1's write lock is global per database. Above ~60 req/s of writes, contention starts to show. Above ~200 req/s, contention dominates. The fix is to put writes through a Cloudflare Queue + Workflow pipeline — a pattern documented in the D1 write-lock postmortem. For lower-traffic SaaS workloads, synchronous writes are fine.

For deeper D1 patterns, see the D1 production guide and the D1 query optimization techniques write-up.

R2: File Storage

R2 is S3-compatible object storage with zero egress fees. The zero-egress detail matters more than it sounds: a SaaS that serves user-uploaded images on every page view would pay a meaningful AWS bill; the same workload on R2 is effectively free for the storage-and-serve path.

The pattern

ts
// Upload handler
export const uploadAvatar = createServerFn().handler(async ({ data }) => {
  const key = `avatars/${crypto.randomUUID()}`;
  await env.STORAGE.put(key, data.file, {
    httpMetadata: { contentType: data.file.type },
  });
  return { key, url: `https://cdn.example.com/${key}` };
});

// Serve via custom domain bound to the R2 bucket
// (configured in the Cloudflare dashboard, not in code)

Bind a custom domain to your R2 bucket for the CDN URL. The setup is one-time in the Cloudflare dashboard; the code never has to construct signed URLs because the custom domain makes the bucket publicly readable.

Queues + Workflows: Background Work

Two primitives, used together for almost every background task.

Queues

Cloudflare Queues is a message queue with at-least-once delivery. Use it for: webhook fanout, email-trigger, batched writes, retry-able jobs.

ts
// Producer
await env.BILLING_QUEUE.send({
  type: "subscription.renewed",
  payload: { userId: 123, amount: 4900 },
});

// Consumer
export default {
  async queue(batch: MessageBatch<unknown>, env: Env) {
    for (const msg of batch.messages) {
      await processBillingEvent(msg.body, env);
      msg.ack();
    }
  },
};

Workflows

Cloudflare Workflows is durable execution with retries, sleeps, and step-based composition. Use it for: multi-step onboarding flows, scheduled jobs, retry-with-backoff logic.

ts
// Workflow definition
export class OnboardingWorkflow implements Workflow {
  async run(event: Payload, step: WorkflowStep) {
    await step.do("create-account", async () => createAccount(event));
    await step.sleep("wait-24h", "24h");
    await step.do("send-day-1-email", async () => sendEmail(event));
  }
}

The combination — Queue for fire-and-forget events, Workflow for multi-step durable execution — covers 95% of background-work patterns in a SaaS. For the remaining 5%, Durable Objects handle stateful coordination (chat rooms, multiplayer games, rate limiters).

Deployment

bash
# Production deploy
npx wrangler deploy

# With secrets
npx wrangler secret put STRIPE_SECRET_KEY
npx wrangler secret put RESEND_API_KEY

# Tail logs
wrangler tail --format=pretty

# D1 insights
wrangler d1 insights --database=my-saas-prod

The first deploy takes about 30 seconds; subsequent deploys are sub-10 seconds. The dashboard at dash.cloudflare.com shows requests, errors, CPU time, and per-route analytics. For deeper observability, pipe wrangler tail to Datadog or Grafana via Logpush.

Monitoring and Logging

Three things I monitor on every Workers app:

  1. CPU time p99 per route. Workers charges for CPU time; long-running routes cost more. If p99 creeps above 50ms, investigate.
  2. Error rate per route. Cloudflare's dashboard shows this; alerts go to Discord via a webhook.
  3. D1 write_lock_wait_ms. This is the early warning of the write-lock incident shape.
bash
# Set up a simple cron alert
watch -n 60 'wrangler d1 insights --database=my-saas-prod | grep write_lock'

Or use Logpush to stream to Datadog and alert there. The exact tooling matters less than the discipline of watching these three metrics.

Cost Analysis

Numbers from my production deployment (TanStack Ship itself):

  • Workers: ~$5/month at ~5M requests/month
  • D1: ~$4/month at 200K reads + 50K writes
  • R2: ~$1/month at 10GB stored + 50GB egress
  • Queues: ~$1/month at 1M messages
  • Workflows: ~$1/month at 50K step executions
  • KV: ~$2/month at 100K reads

Total: roughly $14/month for a SaaS serving 3,200 customers with full SSR, database, file storage, and background work. The equivalent architecture on AWS would be approximately $80–150/month; on Vercel + a managed database, $60–100/month.

The Limits You Should Know

Workers has constraints. Know them upfront.

128MB memory per isolate

For most SaaS routes this is comfortable. For routes that load large datasets, paginate or stream.

CPU time limits

The free plan caps CPU time at 10ms per request (with some exceptions). The paid plan caps at 50ms for the standard tier and 30 seconds for the Unbound tier (if you're on it). Most SSR routes fit inside 50ms; routes that do heavy database work or image processing may not.

No long-lived connections

Workers cannot hold WebSocket connections for hours. Durable Objects handle that case. If you need real-time, plan for DO.

nodejs_compat covers most but not all Node APIs

Some npm packages assume Node-specific behavior that Workers does not provide. The nodejs_compat flag covers most polyfills; for the rest, you may need to find an alternative library. This is rare in 2026 but it happens.

No filesystem

fs is unavailable. All data goes through D1, R2, KV, or DO. This is rarely a constraint for SaaS, but if you reach for fs.readFile, you have to think again.

Putting It All Together

A production SaaS architecture on Cloudflare Workers looks like this:

  • TanStack Start for SSR, routing, type-safe data loading
  • Cloudflare Workers as the runtime
  • D1 for the database (SQLite at the edge)
  • R2 for file storage (zero-egress CDN)
  • Queues for event fanout and async writes
  • Workflows for durable multi-step background work
  • Durable Objects when you need stateful coordination (rate limits, real-time)
  • KV for caches and feature flags
  • Stripe + Resend for payments and email (Workers makes external HTTP calls fine)

This stack runs TanStack Ship in production at $14/month with sub-50ms cold starts and 99.95% uptime. The patterns in this guide are the same ones I would recommend to anyone building a SaaS on Workers in 2026.

For related deep dives, see the streaming SSR explained, the SaaS tech stack 2026 decision framework, and the TanStack Ship features overview.


Get started with TanStack Ship — a 14-module SaaS starter on Cloudflare Workers. Clone the free starter → · See pricing