Skip to main content
Technology & EngineeringCloudflare Workers436 lines

Workers Routing

Request routing in Cloudflare Workers including URL pattern matching, path parameters, middleware patterns, error handling, CORS configuration, custom domains, route priorities, and Workers for Platforms.

Quick Summary26 lines
You are an expert in routing and request handling patterns for Cloudflare Workers, including URL matching, middleware composition, error handling, and multi-tenant routing with Workers for Platforms.

## Key Points

1. More specific hostnames beat wildcards.
2. Longer path prefixes beat shorter ones.
3. If two routes match equally, the first one defined wins.
4. A route with a zone_name beats one without.

## Quick Example

```toml
# Specific routes — more specific patterns take priority
routes = [
  { pattern = "api.example.com/v1/*", zone_name = "example.com" },
  { pattern = "example.com/api/*", zone_name = "example.com" },
]
```

```toml
# Automatically provisions DNS and TLS
[[custom_domains]]
hostname = "api.example.com"
```
skilldb get cloudflare-workers-skills/Workers RoutingFull skill: 436 lines
Paste into your CLAUDE.md or agent config

Workers Routing — Cloudflare Workers

You are an expert in routing and request handling patterns for Cloudflare Workers, including URL matching, middleware composition, error handling, and multi-tenant routing with Workers for Platforms.

Core Philosophy

Overview

Cloudflare Workers receive a Request object and must return a Response. There is no built-in router — you construct routing logic from URL parsing, pattern matching, and middleware composition. This gives full control over how requests are dispatched and processed.

Basic Routing

URL-based routing

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const { pathname } = url;
    const method = request.method;

    // Static routes
    if (method === "GET" && pathname === "/") {
      return new Response("Home");
    }

    if (method === "GET" && pathname === "/api/health") {
      return Response.json({ status: "ok" });
    }

    if (method === "POST" && pathname === "/api/users") {
      return handleCreateUser(request, env);
    }

    return new Response("Not Found", { status: 404 });
  },
};

URLPattern API

Workers support the standard URLPattern API for declarative route matching:

const routes = [
  {
    pattern: new URLPattern({ pathname: "/api/users/:id" }),
    handler: handleGetUser,
  },
  {
    pattern: new URLPattern({ pathname: "/api/users/:userId/posts/:postId" }),
    handler: handleGetUserPost,
  },
  {
    pattern: new URLPattern({ pathname: "/api/products/:slug" }),
    handler: handleGetProduct,
  },
];

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    for (const route of routes) {
      const match = route.pattern.exec(request.url);
      if (match) {
        const params = match.pathname.groups;
        return route.handler(request, env, params);
      }
    }
    return new Response("Not Found", { status: 404 });
  },
};

async function handleGetUser(
  request: Request,
  env: Env,
  params: Record<string, string>
): Promise<Response> {
  const userId = params.id;
  // Fetch user by ID...
  return Response.json({ id: userId, name: "Alice" });
}

Method-aware router

type Handler = (req: Request, env: Env, params: Record<string, string>) => Promise<Response>;

interface Route {
  method: string;
  pattern: URLPattern;
  handler: Handler;
}

function createRouter() {
  const routes: Route[] = [];

  const router = {
    get(path: string, handler: Handler) {
      routes.push({ method: "GET", pattern: new URLPattern({ pathname: path }), handler });
      return router;
    },
    post(path: string, handler: Handler) {
      routes.push({ method: "POST", pattern: new URLPattern({ pathname: path }), handler });
      return router;
    },
    put(path: string, handler: Handler) {
      routes.push({ method: "PUT", pattern: new URLPattern({ pathname: path }), handler });
      return router;
    },
    delete(path: string, handler: Handler) {
      routes.push({ method: "DELETE", pattern: new URLPattern({ pathname: path }), handler });
      return router;
    },

    async handle(request: Request, env: Env): Promise<Response> {
      for (const route of routes) {
        if (request.method !== route.method) continue;
        const match = route.pattern.exec(request.url);
        if (match) {
          return route.handler(request, env, match.pathname.groups as Record<string, string>);
        }
      }
      return new Response("Not Found", { status: 404 });
    },
  };

  return router;
}

const router = createRouter()
  .get("/api/users", listUsers)
  .get("/api/users/:id", getUser)
  .post("/api/users", createUser)
  .put("/api/users/:id", updateUser)
  .delete("/api/users/:id", deleteUser);

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    return router.handle(request, env);
  },
};

Middleware Patterns

Composable middleware

type Middleware = (
  request: Request,
  env: Env,
  next: () => Promise<Response>
) => Promise<Response>;

function compose(...middlewares: Middleware[]) {
  return async (request: Request, env: Env, finalHandler: () => Promise<Response>) => {
    let index = -1;

    async function dispatch(i: number): Promise<Response> {
      if (i <= index) throw new Error("next() called multiple times");
      index = i;

      if (i === middlewares.length) {
        return finalHandler();
      }

      return middlewares[i](request, env, () => dispatch(i + 1));
    }

    return dispatch(0);
  };
}

Authentication middleware

const authMiddleware: Middleware = async (request, env, next) => {
  const token = request.headers.get("Authorization")?.replace("Bearer ", "");

  if (!token) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  try {
    const payload = await verifyJWT(token, env.JWT_SECRET);
    // Attach user info via a header (Workers cannot mutate request objects directly)
    const headers = new Headers(request.headers);
    headers.set("X-User-Id", payload.sub);
    const authedRequest = new Request(request, { headers });
    return next();
  } catch {
    return Response.json({ error: "Invalid token" }, { status: 403 });
  }
};

Logging middleware

const loggingMiddleware: Middleware = async (request, env, next) => {
  const start = Date.now();
  const response = await next();
  const duration = Date.now() - start;

  console.log(
    JSON.stringify({
      method: request.method,
      url: request.url,
      status: response.status,
      duration_ms: duration,
    })
  );

  return response;
};

Error Handling

Global error boundary

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    try {
      return await router.handle(request, env);
    } catch (err) {
      const message = err instanceof Error ? err.message : "Unknown error";
      console.error("Unhandled error:", message);

      if (env.ENVIRONMENT === "development") {
        return Response.json(
          { error: message, stack: err instanceof Error ? err.stack : undefined },
          { status: 500 }
        );
      }

      return Response.json({ error: "Internal Server Error" }, { status: 500 });
    }
  },
};

Custom error classes

class HTTPError extends Error {
  constructor(public status: number, message: string) {
    super(message);
  }
}

class NotFoundError extends HTTPError {
  constructor(resource: string) {
    super(404, `${resource} not found`);
  }
}

class ValidationError extends HTTPError {
  constructor(public errors: Record<string, string>) {
    super(422, "Validation failed");
  }
}

// In the error boundary:
if (err instanceof ValidationError) {
  return Response.json({ error: err.message, details: err.errors }, { status: 422 });
}
if (err instanceof HTTPError) {
  return Response.json({ error: err.message }, { status: err.status });
}

CORS

CORS headers helper

function corsHeaders(origin: string, methods = "GET, POST, PUT, DELETE, OPTIONS"): Headers {
  const headers = new Headers();
  headers.set("Access-Control-Allow-Origin", origin);
  headers.set("Access-Control-Allow-Methods", methods);
  headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
  headers.set("Access-Control-Max-Age", "86400");
  return headers;
}

function handleCORS(request: Request, env: Env): Response | null {
  const origin = request.headers.get("Origin") || "";
  const allowedOrigins = ["https://app.example.com", "https://admin.example.com"];

  if (request.method === "OPTIONS") {
    if (allowedOrigins.includes(origin)) {
      return new Response(null, { status: 204, headers: corsHeaders(origin) });
    }
    return new Response(null, { status: 403 });
  }

  return null; // Not a preflight, continue to handler
}

// Add CORS headers to every response
function withCORS(response: Response, origin: string): Response {
  const newResponse = new Response(response.body, response);
  newResponse.headers.set("Access-Control-Allow-Origin", origin);
  return newResponse;
}

Route Configuration in wrangler.toml

Route patterns and priorities

# Specific routes — more specific patterns take priority
routes = [
  { pattern = "api.example.com/v1/*", zone_name = "example.com" },
  { pattern = "example.com/api/*", zone_name = "example.com" },
]

Route priority rules:

  1. More specific hostnames beat wildcards.
  2. Longer path prefixes beat shorter ones.
  3. If two routes match equally, the first one defined wins.
  4. A route with a zone_name beats one without.

Custom domains (simpler alternative)

# Automatically provisions DNS and TLS
[[custom_domains]]
hostname = "api.example.com"

Workers for Platforms

Workers for Platforms enables multi-tenant architectures where end users deploy their own Workers on your platform.

Dispatch namespace

[[dispatch_namespaces]]
binding = "DISPATCHER"
namespace = "my-platform"
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    // Extract tenant from subdomain: tenant1.platform.com
    const tenant = url.hostname.split(".")[0];

    try {
      // Dispatch to the tenant's Worker
      const worker = env.DISPATCHER.get(tenant);
      return await worker.fetch(request);
    } catch (err) {
      return Response.json({ error: "Tenant worker not found" }, { status: 404 });
    }
  },
};

Outbound Workers (intercept tenant subrequests)

Outbound Workers let the platform intercept and modify subrequests made by tenant Workers — useful for injecting authentication, enforcing rate limits, or rewriting URLs.

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // Tenant's Worker made a fetch() — intercept it here
    const url = new URL(request.url);

    // Block requests to internal services
    if (url.hostname.endsWith(".internal.example.com")) {
      return new Response("Forbidden", { status: 403 });
    }

    // Add platform-level auth
    const headers = new Headers(request.headers);
    headers.set("X-Platform-Key", env.PLATFORM_KEY);

    return fetch(new Request(request, { headers }));
  },
};

Using Hono (Popular Router Framework)

For larger applications, the Hono framework is the community standard:

import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/middleware";

type Bindings = {
  DB: D1Database;
  KV: KVNamespace;
};

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

app.use("*", logger());
app.use("/api/*", cors({ origin: "https://app.example.com" }));

app.get("/api/users", async (c) => {
  const { results } = await c.env.DB.prepare("SELECT * FROM users").all();
  return c.json(results);
});

app.get("/api/users/:id", async (c) => {
  const id = c.req.param("id");
  const user = await c.env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(id).first();
  if (!user) return c.json({ error: "Not found" }, 404);
  return c.json(user);
});

app.onError((err, c) => {
  console.error(err);
  return c.json({ error: "Internal Server Error" }, 500);
});

export default app;

Install this skill directly: skilldb add cloudflare-workers-skills

Get CLI access →