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:
- DNS / custom domain — which hostname hits Cloudflare
- Worker routes / Pages project routes — which script handles that hostname + path
- 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:
# 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:
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:
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:
// 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:
functions/
api/
health.ts → /api/health
tenants/[tenantId].ts → /api/tenants/:tenantId
docs/[[path]].ts → /api/docs/* (optional catch-all)
Dynamic segment example:
// 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
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:
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
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:
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:
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:
- Split hosts when you can:
app.for UI,api.for backend. - If you must share a host, register API routes first and keep the SPA fallback last.
- For Workers Assets + API in one Worker, explicitly exclude API prefixes from the asset fallback.
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:
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_TENANTfallback 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:
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
- Trusting path params without authz — knowing
tenantIdis not the same as being allowed to read it. - One catch-all for everything — harder to cache, harder to secure, harder to observe.
- Forgetting trailing slash normalization —
/docsvs/docs/creating duplicate crawl keys. - Putting secrets in public dynamic paths — API keys in URLs leak via logs and
Referer. - 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.