SaaS Architecture Patterns: The Complete Guide for Solo Developers in 2026

A unified walkthrough of the SaaS architecture patterns I actually ship — frontend, API, data, auth, payments, async, and observability — anchored in 14 production apps on Cloudflare's edge.

Huifer
Huifer
August 14, 202611 min read

Written by Huifer, solo developer and maintainer of TanStack Ship. Across 14 production SaaS apps — billing platforms, analytics dashboards, dev-tools, two B2B marketplaces — I have converged on a single architectural shape: TanStack Start on Cloudflare Workers, D1 for relational data, R2 for blobs, KV for hot reads, Queues for async, Durable Objects for real-time state, Stripe for payments, and a thin observability layer over the whole thing. This guide walks through that shape end-to-end. No vendor sponsorship; every pattern below is in shipped code, not slideware.

Verified sources: TanStack Start Documentation · Cloudflare Workers Documentation · Cloudflare D1 Documentation · Cloudflare R2 Documentation · Cloudflare Durable Objects Documentation · Cloudflare Queues Documentation · Stripe Webhooks Documentation · TanStack Ship GitHub Organization · TanStack Query Documentation

Last updated: 2026-07-09 · Changelog


TL;DR: A solo-shipped SaaS in 2026 does not need twelve services. It needs one edge runtime, one relational store, one object store, one key-value cache, one queue, one durable state primitive, one payment provider, and one observability pipeline. The interesting work is in how those eight primitives are wired together — and that wiring is what this guide is about. If you are choosing a stack, see the TanStack Ship features page; if you want the runtime comparisons, see the tech stack 2026 guide and the TanStack Ship blog index.


The shape of a solo SaaS in 2026

Most solo SaaS architectures fail in one of two directions. Either they reach for twelve services "just in case" and spend the first quarter wiring things together instead of shipping features, or they reach for a single managed platform and discover six months later that one missing primitive — async work, real-time state, file storage — costs them a rewrite.

After fourteen apps, the pattern that keeps surviving is a small, composable set of primitives. According to the Cloudflare Workers documentation, Workers are V8 isolates that boot in single-digit milliseconds and bill by request. According to the Cloudflare D1 documentation, D1 is SQLite at the edge with HTTP-driven access. According to the Cloudflare R2 documentation, R2 is S3-compatible object storage with no egress fees. According to the Cloudflare Durable Objects documentation, Durable Objects are single-tenant compute with strongly consistent per-object storage. None of these primitives are new, but the shape they make together is what I will call the 2026 solo SaaS stack.

The rest of this guide walks through each layer of that stack with real snippets, real tradeoffs, and honest load ceilings. For the broader stack comparison, see the TanStack Start vs Next.js guide and the SaaS tech stack 2026 writeup.

Frontend architecture: file-based routes, type-safe loaders, and one query cache

File-based routing is the right default

The first decision is how routes, layouts, and guards compose. TanStack Start's file-based router is, in my experience, the most boring-correct choice for a solo dev: routes are files, layouts are nested files, guards are route-level beforeLoad callbacks, and search params are parsed by Zod. I have shipped apps with nested auth gates, parallel routes, and streaming SSR all without reaching for a custom router. According to the TanStack Router documentation, every file under src/routes/ becomes a route; co-located _layout.tsx files become shared layouts.

A real guard from the billing module:

typescript
// src/routes/_authed/billing.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
import { z } from 'zod'

export const Route = createFileRoute('/_authed/billing')({
  beforeLoad: async ({ context }) => {
    if (!context.user) throw redirect({ to: '/login' })
    if (!context.user.stripeCustomerId) throw redirect({ to: '/billing/setup' })
  },
  validateSearch: z.object({
    tab: z.enum(['invoices', 'subscription', 'history']).default('invoices'),
  }),
  loader: async ({ context }) => {
    return await context.queryClient.ensureQueryData(billingQueryOptions(context.user.id))
  },
  component: BillingPage,
})

The _authed segment prefix makes the guard file-system-scoped. I do not write a single global auth check; the route shape enforces it. For nested layouts with parallel routes, the TanStack Router advanced patterns guide walks through the file conventions.

One query client, three cache layers

The biggest frontend mistake I see solo devs make is running two state libraries — TanStack Query for server state and Zustand for everything else — and then discovering that "everything else" quietly became server state. The cleaner shape is one query client with three cache layers:

  1. Server state — anything fetched, owned by the backend, invalidated by tag.
  2. URL state — search params, owned by the route, parsed by Zod, never duplicated in a store.
  3. UI state — modals, hover, focus, owned by the component.

The TanStack Query documentation calls this out explicitly. Tag-based invalidation is the load-bearing piece. Every server function emits a tag; every query subscribes to a tag; mutations invalidate by tag. For deeper patterns, the TanStack Query server functions guide and the cache invalidation guide cover the specifics.

API architecture: server functions are the API

Stop writing controllers

The second-biggest mistake is treating the API as a separate layer with its own routing, its own auth, its own validation, and its own types — duplicated against the frontend. TanStack Start's server functions collapse all of that. The same TypeScript file holds the request shape, the auth check, the Zod parse, the database call, and the typed response. The build step creates the RPC; the runtime validates at the boundary.

typescript
// src/server-fn/billing.ts
import { createServerFn } from '@tanstack/start'
import { z } from 'zod'
import { db } from '~/lib/db'
import { requireUser } from '~/lib/auth'

export const createCheckout = createServerFn({ method: 'POST' })
  .validator(z.object({ priceId: z.string().startsWith('price_') }))
  .handler(async ({ data, request }) => {
    const user = await requireUser(request)
    const session = await stripe.checkout.sessions.create({
      customer: user.stripeCustomerId,
      line_items: [{ price: data.priceId, quantity: 1 }],
      mode: 'subscription',
      success_url: `${request.headers.get('origin')}/billing?status=success`,
      cancel_url: `${request.headers.get('origin')}/billing?status=cancel`,
    })
    await db.insert(checkouts).values({
      userId: user.id, sessionId: session.id, priceId: data.priceId, status: 'pending',
    })
    return { url: session.id }
  })

There is no /api/create-checkout route. There is no OpenAPI doc to maintain. The frontend imports the server function directly:

typescript
import { createCheckout } from '~/server-fn/billing'
const session = await createCheckout({ data: { priceId } })
window.location = `https://checkout.stripe.com/c/pay/${session.url}`

For error-handling specifics, the TanStack Start server function error handling guide covers the typed failure surface.

When to leave server functions

Server functions are right for roughly 95% of API surface. The 5% where they are wrong:

  • Webhook receivers. Stripe, GitHub, and any third-party POST need a public HTTP endpoint with raw-body access for signature verification. Webhooks do not run through createServerFn.
  • Long-running work. Anything over the request-response budget (~30 seconds for Workers CPU, much less in practice) goes through Queues.
  • Cross-region fanout. Server functions run in the region closest to the caller; for global fanout, you want a Queue or a Durable Object broadcast.

The boundary between "server function" and "queue" and "Durable Object" is the most important architectural decision you make after the route shape. I will come back to it in the async section.

Data architecture: D1, R2, and the index you actually need

One relational store, indexed for the queries you have

D1 is SQLite at the edge. The temptation is to over-normalize because "edge database" sounds exotic. Resist it. A well-indexed three-table schema runs faster than a fancy four-table schema with NULLs everywhere. The index work pays off the same way it does in any SQLite database — the Cloudflare D1 best practices guide lists the four levers, and the D1 query optimization guide walks each one with EXPLAIN output.

The patterns I lean on most, in order of payoff:

  1. Composite indexes in predicate order. A (user_id, status, created_at) index serves the query WHERE user_id = ? AND status = ? ORDER BY created_at DESC without a sort.
  2. db.batch() for multi-statement writes. Latency is per-statement; batching collapses it.
  3. resolve: "primary" for post-write reads. Read replicas lag by milliseconds; for "just wrote, now read" paths, hit the primary.
  4. KV as the hot-read cache, D1 as source of truth. Per the Cloudflare KV documentation, KV is eventually consistent and global; I use it for the public landing-page metrics, dashboard tile counts, and rate-limit counters.

R2 for blobs, never for hot paths

Object storage is for files. User uploads, exported CSVs, generated PDFs, video clips — anything over a few hundred KB belongs in R2 and is served via a signed URL or a public bucket. According to the Cloudflare R2 documentation, R2 bills only for storage and operations, not egress, which makes it the cheapest credible object store for SaaS. The file upload architecture guide walks the upload-from-browser pattern with a presigned URL and a webhook to confirm completion.

What does not belong in R2: anything you query by secondary index. Object stores are not databases; if you find yourself wanting to filter "all uploads created this week by this user", you have a metadata problem that belongs in D1.

Auth architecture: edge sessions, not JWTs in localStorage

Better-auth on Workers is the boring-correct choice

The auth architecture I ship is built on Better Auth running on Workers, with sessions stored in D1 and verified at the edge. The pattern is well-trodden and avoids the worst pitfalls of JWTs — revocation, refresh rotation, cross-tab race conditions. The full session lifecycle and the race-condition bug I hit are documented in the Better Auth edge session race postmortem.

The shape:

typescript
// src/lib/auth.ts
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { db } from '~/lib/db'

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: 'sqlite' }),
  emailAndPassword: { enabled: true, requireEmailVerification: true },
  socialProviders: { github: { clientId: env.GITHUB_ID, clientSecret: env.GITHUB_SECRET } },
  session: { expiresIn: 60 * 60 * 24 * 30, updateAge: 60 * 60 * 24 },
})

// Every server function calls requireUser(request) before touching the database.

The single rule is: never trust the cookie on the client. The session id is a value the browser sends; the only thing that establishes identity is the server-side lookup. Every server function, every loader, every webhook that mutates user data calls requireUser first.

RBAC is a route concern, not an auth concern

Roles and permissions are route-level concerns. The auth layer answers "who is this user"; the route decides "what can this user do". A simple beforeLoad check on admin routes is enough:

typescript
beforeLoad: async ({ context }) => {
  const user = await requireUser(context.request)
  if (user.role !== 'admin') throw redirect({ to: '/' })
}

For multi-tenant apps — where the role is scoped per workspace, not globally — the multi-tenant architecture guide walks through the workspace-scoped guard pattern. I do not recommend reaching for a third-party RBAC library until you have at least four roles and at least three resources; before that point, route guards are simpler and faster to debug.

Payments architecture: Stripe webhooks with idempotency keys

The webhook is the source of truth

The single biggest payment bug I have shipped, debugged, and written a postmortem about is treating the Stripe redirect URL as the source of truth for subscription state. It is not. The webhook is. The user can close the tab, lose the connection, or never see the success page; the webhook will arrive regardless. According to the Stripe webhooks documentation, webhooks are POST requests to a public endpoint with a signature header; you verify the signature, then process the event.

The shape of a webhook handler that does not double-charge, lose events, or quietly desync:

typescript
// src/routes/api/webhooks/stripe.ts
import { createFileRoute } from '@tanstack/react-router'
import Stripe from 'stripe'

const stripe = new Stripe(env.STRIPE_SECRET_KEY)

export const Route = createFileRoute('/api/webhooks/stripe')({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const sig = request.headers.get('stripe-signature')!
        const body = await request.text()
        const event = stripe.webhooks.constructEvent(body, sig, env.STRIPE_WEBHOOK_SECRET)
        const stored = await db.insert(webhookEvents).values({
          id: event.id, type: event.type, payload: JSON.stringify(event), receivedAt: new Date(),
        }).onConflictDoNothing()

        if (stored.changes === 0) {
          return Response.json({ ok: true, duplicate: true })
        }

        switch (event.type) {
          case 'customer.subscription.created':
          case 'customer.subscription.updated':
            await syncSubscription(event.data.object)
            break
          case 'invoice.payment_failed':
            await flagDunning(event.data.object)
            break
        }
        return Response.json({ ok: true })
      },
    },
  },
})

The first INSERT — with the Stripe event id as the primary key — is the idempotency guard. If a duplicate webhook arrives, the INSERT fails silently and the handler returns 200. The full postmortem, including the duplicate-delivery bug that led to this shape, is in the Stripe webhook duplicate delivery postmortem. For the broader payment-failure handling, the dunning and payment failures guide walks the recovery ladder.

Async architecture: when server functions end and queues begin

The three-second rule

The mental model I ship with: if a server function does work that takes longer than three seconds for any realistic request, the work belongs in a queue. According to the Cloudflare Queues documentation, Queues are durable, batched message consumers with a per-message visibility timeout and at-least-once delivery. The pattern:

typescript
// Producer — a server function that finished its hot path
await env.BILLING_QUEUE.send({
  userId: user.id,
  type: 'send-invoice-email',
  invoiceId: invoice.id,
  attempt: 0,
})

// Consumer — runs out-of-band, retries on failure
export default {
  async queue(batch, env) {
    for (const msg of batch.messages) {
      try {
        await sendInvoiceEmail(msg.body, env)
        msg.ack()
      } catch (e) {
        if (msg.body.attempt < 5) msg.retry({ delaySeconds: 2 ** msg.body.attempt * 10 })
        else { await dlq.send(msg.body); msg.ack() }
      }
    }
  },
}

The use cases I reach for Queues for: emails, PDF generation, CSV exports, webhook fanout to external systems, and any "do this once the user has left the page" work. The Cloudflare Queues guide walks through the producer-consumer pattern in more depth, and the email queue duplicate batch postmortem covers the idempotency gotcha.

Durable Objects for stateful real-time

For stateful real-time — chat rooms, collaborative cursors, live dashboards, multiplayer games — Durable Objects are the right primitive. According to the Cloudflare Durable Objects documentation, each DO is a single-tenant isolate with strongly consistent per-object storage and WebSocket support. The Durable Objects real-time guide walks through the WebSocket hibernation pattern I ship for live presence.

The boundary is simple: if you need cross-instance state with sub-second consistency, you need a DO. If you need cross-instance work that can tolerate at-least-once delivery, you need a Queue. If you need neither, you need a server function. That sentence is the entire async architecture.

Observability architecture: one log shape, three signals

Logs, traces, and the single missing metric

The observability architecture I ship is three signals: structured logs, request traces, and the one metric per route that actually matters (p95 latency for the user-facing path, error rate for the webhook path). According to the Cloudflare Workers logging documentation, Workers emit console.log as structured JSON when configured with tail: true, and Workers Analytics Engine provides a low-cardinality time-series store at sub-second resolution.

What I do not ship: per-event dashboards with thirty widgets. The dashboard is the trace. If a trace looks wrong, the log stream shows the cause. The real user monitoring guide covers the browser-side signal; the monitoring and observability guide walks through the server-side pipeline.

The pattern that survives every incident: every server function emits a structured log line with userId, route, durationMs, status. Every webhook emits the same shape with eventId. When something goes wrong at 2 a.m, the first thing I do is tail -f and grep for status: error — and the line tells me exactly which user, which route, how long it took, and which downstream call failed. That is the entire observability story.

Where this guide stops

This is the shape of a solo SaaS in 2026. Eight primitives, one wiring, and a hard rule: every layer in the stack is replaceable by an equivalent primitive, but the shape of the wiring stays the same. If you replace Cloudflare with Fly or Render, the data architecture still holds. If you replace TanStack Start with Next.js or Remix, the API architecture still holds. The shape is durable; the providers are not.

What this guide does not cover is the long tail of business architecture — pricing, churn, customer success, growth. Those are separate guides. The stack described here is the substrate those run on, not the company that runs on top of it.


Closing CTA: The TanStack Ship starter is the production version of every pattern in this guide, wired together and tested in fourteen shipping apps. See the features page for the module breakdown, or compare TanStack Ship against the alternatives if you are choosing a starter. If you want the broader stack decisions, the SaaS tech stack 2026 guide and the TanStack Start deployment guide cover the runtime and hosting side. For the data layer specifically, the D1 production guide and the D1 query optimization techniques walk through the patterns referenced above.