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 -Iandcurl -L, and diagnose loops, double slashes, lost query strings, and cache-key drift.
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.
Related
- Modifying Request Headers at the CDN Edge Layer
- Deploying Cloudflare Workers for Dynamic Request Routing
Back to Request/Response Transformation