← Back to Blog

Cloudflare Dynamic Routes: From Workers to Multi-Tenant Paths

#cloudflare#workers#routing#edge#web-development#serverless

Static hosting is easy. Dynamic routes are where edge platforms either feel magical or fight you for a week. On Cloudflare, you can ship path-based APIs, tenant-scoped widgets, and catch-all document crawlers — if you understand how routing actually works across Workers, Pages, and custom domains.

This is the mental model I use when I build products like UseDocs on Cloudflare.

What “dynamic routes” means on Cloudflare

On a traditional Node server, a dynamic route is usually a framework path like /api/docs/:slug or /t/:tenantId/*. On Cloudflare you still write those paths, but the edge decides which Worker (or Pages Function) receives the request before your code runs.

There are three layers to keep straight:

  1. DNS / custom domain — which hostname hits Cloudflare
  2. Worker routes / Pages project routes — which script handles that hostname + path
  3. In-app routing — how your code parses params and dispatches handlers

If layer 2 is wrong, your beautiful Hono routes never run. If layer 3 is wrong, you get 404s even when the Worker is hot. Debug the edge first, then the framework.

Workers: path patterns on the edge

When you attach a Worker to a route, Cloudflare matches hostname + path pattern. Common patterns:

txt
# Exact-ish path prefix
api.example.com/v1/*

# Everything under a product host
app.example.com/*

# Zone-wide API surface
example.com/api/*

Wildcards are powerful and easy to misconfigure. A few rules that save pain:

  • Prefer one Worker per product surface (API Worker, widget Worker, admin Worker) over one mega-Worker with a dozen unrelated route entries.
  • Put more specific routes above catch-alls in your mental model — Cloudflare picks the most specific route match.
  • Separate public widget origins from authenticated API origins. CORS and cookies behave better when hosts are intentional.

Example wrangler.toml sketch for an API Worker:

toml
name = "docs-api"
main = "src/index.ts"
compatibility_date = "2026-01-01"

routes = [
  { pattern = "api.usedocs.app/*", zone_name = "usedocs.app" }
]

[[d1_databases]]
binding = "DB"
database_name = "usedocs"
database_id = "..."

[[r2_buckets]]
binding = "DOCS"
bucket_name = "usedocs-docs"

The route pattern is not your app router — it only decides that this Worker runs. Inside the Worker, you still need real path parsing.

In-Worker routing: params that feel like Express

Raw fetch handlers work, but production apps almost always use a router. Hono is a solid default on Workers:

typescript
import { Hono } from "hono";

type Env = {
  DB: D1Database;
  DOCS: R2Bucket;
};

const app = new Hono<{ Bindings: Env }>();

// /v1/tenants/:tenantId/docs/:docId
app.get("/v1/tenants/:tenantId/docs/:docId", async (c) => {
  const { tenantId, docId } = c.req.param();

  const row = await c.env.DB.prepare(
    `SELECT id, title, path FROM documents
     WHERE tenant_id = ? AND id = ?`
  )
    .bind(tenantId, docId)
    .first();

  if (!row) return c.json({ error: "Not found" }, 404);
  return c.json(row);
});

// Catch-all for embeddable widget assets under /w/*
app.get("/w/*", async (c) => {
  const key = c.req.path.replace(/^\/w\//, "");
  const object = await c.env.DOCS.get(`widgets/${key}`);
  if (!object) return c.notFound();

  return new Response(object.body, {
    headers: {
      "content-type": object.httpMetadata?.contentType ?? "application/javascript",
      "cache-control": "public, max-age=300",
    },
  });
});

export default app;

Path params vs query params

Use path params for identity (tenantId, conversationId, slug) and query params for filters (?cursor=, ?status=). Edge caches, logs, and rate limits are easier when resource identity lives in the path.

Optional and catch-all segments

For crawlable or user-defined doc trees, catch-alls matter:

typescript
// Matches /docs/getting-started and /docs/guides/auth/mfa
app.get("/docs/*", async (c) => {
  const slug = c.req.path.replace(/^\/docs\//, ""); // "guides/auth/mfa"
  // resolve against Vectorize / D1 / R2 using tenant + slug
});

Treat the catch-all as untrusted input. Normalize slashes, reject .., and never use it as a raw filesystem path into R2 without a tenant prefix.

Pages Functions: file-based dynamic routes

If your frontend lives on Cloudflare Pages, Functions use a file-based convention similar to other meta-frameworks:

txt
functions/
  api/
    health.ts                 → /api/health
    tenants/[tenantId].ts     → /api/tenants/:tenantId
    docs/[[path]].ts          → /api/docs/* (optional catch-all)

Dynamic segment example:

typescript
// functions/api/tenants/[tenantId].ts
export async function onRequestGet(context: EventContext) {
  const tenantId = context.params.tenantId as string;

  // context.env has your bindings
  return Response.json({ tenantId });
}

When to use Pages Functions vs a dedicated Worker:

| Need | Prefer | |------|--------| | Marketing site + a few APIs | Pages Functions | | Long-running product API, Queues, DO, heavy bindings | Dedicated Worker | | Embeddable third-party script origin | Dedicated Worker + custom domain | | Multi-tenant subdomain routing | Worker + custom domains / SaaS |

For UseDocs-style products, I keep the app shell on Pages (or Vite static on Workers Assets) and put API + agents + crawl on Workers with full bindings.

Multi-tenant dynamic routes

This is the hard version of dynamic routing: every customer looks like their own product surface.

Pattern A: path-based tenants

txt
https://app.example.com/t/acme/docs
https://app.example.com/t/acme/chat

Pros: simple DNS, one certificate story.
Cons: easy to leak tenant context if middleware forgets to scope queries.

Always resolve tenant once in middleware and thread a typed context:

typescript
app.use("/t/:tenantSlug/*", async (c, next) => {
  const slug = c.req.param("tenantSlug");
  const tenant = await c.env.DB.prepare(
    "SELECT id, plan FROM tenants WHERE slug = ? AND active = 1"
  )
    .bind(slug)
    .first<{ id: string; plan: string }>();

  if (!tenant) return c.json({ error: "Unknown tenant" }, 404);

  c.set("tenant", tenant);
  await next();
});

Every D1/R2/Vectorize call should include tenant.id. Dynamic routes without tenant scoping are multi-tenant bugs waiting to happen.

Pattern B: subdomain tenants

txt
https://acme.example.com/docs
https://globex.example.com/docs

Pros: cleaner customer URLs, stronger isolation story.
Cons: DNS + SSL + local dev complexity.

On Cloudflare you typically combine:

  • a wildcard DNS record (*.example.com)
  • a Worker route on *.example.com/*
  • hostname parsing in code:
typescript
function tenantFromHost(host: string): string | null {
  // acme.example.com → acme
  const base = "example.com";
  if (!host.endsWith(base) || host === base || host === `www.${base}`) {
    return null;
  }
  return host.slice(0, -(base.length + 1)).split(".")[0] ?? null;
}

Pattern C: customer custom domains

Customers map docs.theircompany.com → your Worker. Cloudflare for SaaS / custom hostnames is the production path. Your router then keys off the full hostname, not a path segment:

typescript
const tenant = await resolveTenantByCustomDomain(c.env.DB, c.req.header("host"));

Dynamic routes still exist under that host (/api/chat, /widget.js) — the tenant is just resolved earlier.

SPA fallback vs API routes

A classic trap: you ship a React SPA on app.example.com/* and also put APIs on the same host. The SPA catch-all starts swallowing /api/* or vice versa.

Rules that work:

  1. Split hosts when you can: app. for UI, api. for backend.
  2. If you must share a host, register API routes first and keep the SPA fallback last.
  3. For Workers Assets + API in one Worker, explicitly exclude API prefixes from the asset fallback.
typescript
app.get("*", async (c) => {
  if (c.req.path.startsWith("/api/") || c.req.path.startsWith("/v1/")) {
    return c.json({ error: "Not found" }, 404);
  }
  // return index.html for client-side routes
  return c.env.ASSETS.fetch(c.req.raw);
});

Caching dynamic routes without serving the wrong tenant

Edge caching + dynamic multi-tenant routes is a sharp edge. Defaults:

  • Never cache authenticated JSON across users.
  • If you cache public docs HTML/API, vary by tenant identity:
typescript
return new Response(body, {
  headers: {
    "cache-control": "public, max-age=60",
    "vary": "host",
    // or a custom header you control for tenant id
  },
});

For private chat/completions endpoints: Cache-Control: no-store.

Local dev and route parity

wrangler dev will not perfectly mirror every production route rule. I keep a small checklist:

  • Same path prefixes in local and prod (/v1/... not /api/... in one env and the other)
  • Bindings named identically (DB, VECTORIZE, AI)
  • A DEV_TENANT fallback only for local — never trusted in production middleware
  • Integration tests that hit real path shapes: /v1/tenants/acme/docs/nested/path

A practical route map for an AI docs product

If you’re building something like UseDocs, a clean split looks like this:

txt
api.product.com
  GET  /v1/health
  POST /v1/chat
  GET  /v1/tenants/:id/docs/*
  POST /v1/tenants/:id/crawl
  GET  /v1/widget/:tenantKey/bootstrap

app.product.com
  /*                 → dashboard SPA

cdn.product.com
  /widget.js         → embed script
  /w/:tenant/*       → tenant-scoped static assets

Dynamic where it must be (tenant, doc path, conversation). Static and boring where it can be (health, widget bootstrap file names).

Common mistakes

  1. Trusting path params without authz — knowing tenantId is not the same as being allowed to read it.
  2. One catch-all for everything — harder to cache, harder to secure, harder to observe.
  3. Forgetting trailing slash normalization/docs vs /docs/ creating duplicate crawl keys.
  4. Putting secrets in public dynamic paths — API keys in URLs leak via logs and Referer.
  5. Assuming Pages and Workers route the same way — test the deployment target you actually use.

Wrap-up

Cloudflare dynamic routes are not just :id syntax. They’re the combination of edge route attachment, hostname strategy, and in-app param handling — especially once multi-tenant AI products enter the picture.

Get the host and Worker route right, parse params with a real router, scope every data access by tenant, and keep SPA fallbacks from eating your API. Do that, and the edge stops feeling mysterious and starts feeling like a very fast place to ship.

Next up: how those routing choices show up in a real product — how I built UseDocs on Cloudflare.

Go Home