Rewriting URLs and Paths at the Edge

URL rewriting at the edge lets you change the path, host, or query a request carries before it reaches your origin — without changing what the browser sees in its address bar. By the end of this guide you will be able to implement transparent rewrites in a Cloudflare Worker, build the same logic with no-code Transform Rules, strip prefixes, rewrite hosts for multi-origin backends, normalize trailing slashes, and prove the result with curl while avoiding the redirect loops that take edge configs down.

Key objectives:

  • Distinguish a transparent rewrite (origin sees a new path, client sees the original URL, response stays 200) from a visible redirect (301/302, client URL changes).
  • Implement prefix stripping, host rewrites, and trailing-slash normalization in a Worker while preserving query strings and headers.
  • Reproduce the same behavior with Cloudflare Transform Rules (URL Rewrite) for cases that need no code.
  • Verify each rewrite with curl -I and curl -L, and diagnose loops, double slashes, lost query strings, and cache-key drift.
Transparent rewrite versus visible redirect Top row shows a rewrite where the origin receives a changed path and the client keeps its URL with a 200 response. Bottom row shows a redirect where the edge returns 301 and the client re-requests. Rewrite vs Redirect at the edge Transparent rewrite Client /blog/post Edge rewrite path Origin /content/post 200 OK URL bar unchanged Visible redirect Client /old Edge Location: /new 301 / 302 URL bar changes client re-requests /new (extra round trip)

Rewrite versus redirect: pick the right tool

A rewrite is server-side and invisible. The edge receives /blog/my-post, internally fetches /content/blog/my-post from origin, and returns the body with a 200. The browser never learns the path changed, so links, bookmarks, and analytics keep the public URL. A redirect is client-side: the edge answers with 301 or 302 and a Location header, and the browser issues a fresh request to the new URL. Redirects cost an extra round trip and expose the target, but they are the correct choice when you are permanently moving a canonical URL and want search engines and clients to update their records.

Concern Transparent rewrite Visible redirect
Status code 200 (origin’s status) 301 / 302 / 307 / 308
Client URL bar Unchanged Changes to target
Round trips One Two
SEO signal None (URL is canonical) Passes link equity to target
Best for Prefix stripping, host rewrites, internal restructuring Domain moves, HTTP→HTTPS, deprecating old paths

Use rewrites when the public URL must stay stable but your origin layout differs — for example serving a marketing site and an app from one hostname, or hiding a /v2/ API prefix. Use redirects for permanent moves. The rest of this guide focuses on rewrites, with redirect handling shown so you can avoid accidentally turning one into the other.

Prerequisites and environment setup

You need a domain proxied through Cloudflare (orange-cloud DNS record) so that requests transit the edge. For the Worker path, install and authenticate Wrangler:

npm install -g wrangler@3
wrangler --version
# ⛅️ wrangler 3.78.0
wrangler login

Confirm the hostname is proxied before testing — an unproxied (grey-cloud) record bypasses Workers and Transform Rules entirely. If you are new to deploying Worker routes, the mechanics of binding a Worker to a hostname pattern are covered in deploying Cloudflare Workers for dynamic request routing. For header-level edits that often pair with path rewrites, see modifying request headers at the CDN edge layer.

Step-by-step: rewrites in a Cloudflare Worker

A Worker rewrite has one rule: construct a new URL, mutate only what you intend, then build a new Request that copies the original headers, method, and body. Never mutate the incoming request’s URL in place — it is immutable, and skipping the copy is how query strings and bodies get dropped.

Step 1: Strip a path prefix and preserve the query

This Worker removes a leading /app prefix so the origin sees /dashboard?tab=billing instead of /app/dashboard?tab=billing.

export default {
  async fetch(request) {
    const url = new URL(request.url);

    // Only the pathname changes; url.search (the query) is untouched.
    if (url.pathname.startsWith("/app/")) {
      url.pathname = url.pathname.replace(/^\/app/, "");
    }

    // Build a new Request: this copies method, headers, and body.
    const rewritten = new Request(url.toString(), request);
    return fetch(rewritten);
  },
};

Because new URL parses search separately, ?tab=billing survives automatically — you only touched pathname. The new Request(url, request) form is the critical line: it inherits headers and body from the original, so Authorization, Cookie, and POST payloads pass through unchanged. Expected side effect: the origin access log shows /dashboard, the client still shows /app/dashboard, and the response is 200.

Step 2: Rewrite the host for a multi-origin backend

To route /api/* to a separate origin while everything else hits the default backend, rewrite the hostname:

export default {
  async fetch(request) {
    const url = new URL(request.url);

    if (url.pathname.startsWith("/api/")) {
      url.hostname = "api-backend.internal.example.com";
      url.pathname = url.pathname.replace(/^\/api/, "");
    }

    const rewritten = new Request(url.toString(), request, {
      // Preserve the original Host header if the origin keys on it.
      headers: request.headers,
    });
    return fetch(rewritten);
  },
};

Note: changing url.hostname changes the SNI and the Host Cloudflare sends upstream. If your origin virtual-hosts on the original Host, set it explicitly with rewritten.headers.set("Host", "www.example.com") (subject to Worker header restrictions) or send the public host in a custom header your origin trusts.

Step 3: Normalize trailing slashes without a redirect loop

Trailing-slash normalization is where loops are born. If you redirect /about//about but the origin redirects /about/about/, the client bounces forever. The safe pattern is to normalize as a rewrite (no client redirect) or to redirect only one direction and confirm the origin agrees.

export default {
  async fetch(request) {
    const url = new URL(request.url);

    // Collapse accidental double slashes, then strip a single trailing slash
    // (but never the root "/").
    url.pathname = url.pathname.replace(/\/{2,}/g, "/");
    if (url.pathname.length > 1 && url.pathname.endsWith("/")) {
      url.pathname = url.pathname.slice(0, -1);
    }

    return fetch(new Request(url.toString(), request));
  },
};

This rewrites silently — the client never sees a 301, so there is no loop risk. Deploy with wrangler deploy and the route bound to your hostname.

Step 4: When you do want a visible redirect

If the goal is a real 301, return a Response instead of calling fetch. Guard against loops by redirecting only when the URL is not already in its target shape:

export default {
  async fetch(request) {
    const url = new URL(request.url);
    if (url.pathname.startsWith("/old-blog/")) {
      url.pathname = url.pathname.replace(/^\/old-blog/, "/blog");
      return Response.redirect(url.toString(), 301);
    }
    return fetch(request);
  },
};

The startsWith guard ensures /blog/* requests are never matched again, so the redirect fires exactly once.

Step-by-step: the no-code alternative with Transform Rules

For static prefix or path rewrites you do not need a Worker. Cloudflare Transform Rules → Rewrite URL does transparent path and query rewrites declaratively, runs before Workers, and adds no compute cost. In the dashboard go to Rules → Transform Rules → Rewrite URL → Create rule, or express it in Terraform/API. A rule that strips /app looks like this:

# Filter expression (when incoming requests match)
starts_with(http.request.uri.path, "/app/")

# Path rewrite (dynamic)
regex_replace(http.request.uri.path, "^/app", "")

# Query rewrite: "Preserve" (leave http.request.uri.query unchanged)

Choosing Preserve for the query is the no-code equivalent of leaving url.search alone in the Worker — pick it unless you explicitly need to rewrite the query, or you will silently drop parameters. Transform Rules are best for fixed patterns; reach for a Worker when the rewrite depends on cookies, geolocation, KV lookups, or anything dynamic, the same boundary discussed under Request/Response Transformation.

Capability Transform Rules Worker
Static prefix strip / path map Yes Yes
Host rewrite No (path/query only) Yes
Conditional on cookie / geo / KV No Yes
Compute cost None Per-request
Deployment Dashboard / Terraform wrangler deploy

Verification

The single most useful check is the status code. A rewrite returns the origin’s 200; a redirect returns 3xx with a Location header. Use curl -I to see headers without following them:

curl -I https://www.example.com/app/dashboard?tab=billing
# HTTP/2 200            <- transparent rewrite worked, no redirect
# server: cloudflare

If you instead see a redirect, follow the full chain with -L and the write-out format to confirm it terminates in exactly one hop:

curl -sL -o /dev/null -w "%{num_redirects} hops -> %{url_effective}\n" \
  https://www.example.com/old-blog/launch
# 1 hops -> https://www.example.com/blog/launch

Confirm the query string survived by echoing it from a test origin or checking the access log — the rewritten request should still carry ?tab=billing. To watch what your Worker actually computed in real time, stream logs:

wrangler tail --format pretty
# GET https://www.example.com/app/dashboard?tab=billing - Ok
#   (origin fetched as /dashboard?tab=billing)

Troubleshooting

Redirect loop (curl -L reports too many redirects). Run curl -sL -o /dev/null -w "%{num_redirects}\n" <url>; a number above 1 (or curl: (47) Maximum redirects followed) signals a loop. The usual cause is the edge and origin disagreeing on trailing slashes. Fix by converting one side to a rewrite, or by adding a guard so the redirect rule cannot match its own output (startsWith on the target prefix).

Double slashes in the upstream path (//dashboard). This happens when you strip a prefix that included a trailing slash and the remainder already starts with /. Diagnose with wrangler tail and look at the fetched path. Fix by collapsing runs of slashes after the rewrite: url.pathname = url.pathname.replace(/\/{2,}/g, "/").

Lost query string. If the origin no longer receives ?tab=billing, you likely rebuilt the URL from pathname alone or set the Transform Rule query to a literal instead of Preserve. In a Worker, mutate url.pathname and call url.toString() so search is reattached; never concatenate paths by hand.

Cache returns the wrong variant after a rewrite. Cloudflare’s cache key is computed from the request URL. If you rewrite host or path inside a Worker, the cache may key on the pre- or post-rewrite URL depending on whether you fetch() the new request, polluting the hit ratio. Verify with curl -I and inspect cf-cache-status; if rewrites and originals share a key incorrectly, set an explicit cache key (Cache Rules or fetch(req, { cf: { cacheKey } })) so each public URL maps to one cache entry. Path-altering Transform Rules update the cache key automatically, which is another reason to prefer them for static rewrites.

Rewrite never fires. Confirm the DNS record is proxied (orange cloud) and the Worker route pattern matches the full hostname and path (www.example.com/app/*). A grey-cloud record or a too-narrow route silently bypasses the edge, and curl -I will show your origin’s response unchanged.

Frequently Asked Questions

Does a rewrite change what search engines index? No. A transparent rewrite keeps the public URL and returns 200, so crawlers index the original address. Use a 301 redirect when you actually want to move the canonical URL.

Can Transform Rules rewrite the hostname to another origin? No. Rewrite URL rules only touch path and query. To send traffic to a different backend host you need a Worker that sets url.hostname, or origin-level routing.

Why did my POST body disappear after rewriting? You probably built a new Request from the URL string without passing the original request as the second argument. new Request(url, request) copies the method and body; omitting request creates an empty GET.

How do I keep the query string when stripping a prefix? Mutate only url.pathname and serialize with url.toString(). The URL object stores the query in url.search separately, so it is preserved automatically. In Transform Rules, set the query operation to Preserve.

Should I normalize trailing slashes with a rewrite or a redirect? Use a rewrite when the public URL does not need to change — it avoids the extra round trip and the loop risk entirely. Use a single-direction 301 only if you need one canonical slash form for SEO, and confirm the origin does not redirect the other way.

Back to Request/Response Transformation