Definition
If you have ever opened /blog on your own domain only for the browser to give up with "too many redirects" — while your application code contains no redirect at all — this is the concept you need.
A rewrite keeps the address bar unchanged and has the server fetch the content from an upstream on your behalf: it is a proxy. A redirect (a 3xx response) instead tells the browser to go somewhere else. The trap is that a rewrite does not transform the upstream response. If the upstream answers with a 3xx, that 3xx is handed straight to the browser. When the upstream is a SaaS that canonicalizes back to your own domain, you get an endless round trip between your custom domain and the origin.
Why it matters
Delegating only the blog, docs, or status page to a SaaS while making it look like a sub-path of your own domain is a very common setup. If you understand a rewrite as merely "quietly fetching it internally," you will not predict the 3xx pass-through.
Many SaaS products redirect requests that arrive at the tenant address (saas.com/<tenant>) to the customer's canonical domain (mydomain.com/blog) with a 308, in order to avoid duplicate URLs for SEO. But that canonical domain rewrites back to the SaaS, so the request circles forever. The symptom is ERR_TOO_MANY_REDIRECTS, and since no redirect exists anywhere in your repository, the cause is hard to trace.
How it works
- The browser requests
https://mydomain.com/blog. - Your application server makes a server-to-server request to
https://saas.com/tenantper the rewrite rule. - The SaaS answers
308 Location: https://mydomain.com/blogper its canonical policy. - Because a rewrite is a proxy, that 308 is passed to the browser unmodified.
- The browser follows
Locationback to step 1 — infinite loop.
| Aspect | Redirect | Rewrite |
|---|---|---|
| Address bar | Changes | Unchanged |
| Response | Generates the 3xx itself | Relays the upstream response |
| Upstream 3xx | Not applicable | Passed straight through |
| Loop risk | When rules cycle | When the upstream points back at you |
In practice
First, confirm — do not assume — that the destination can be proxied.
curl -I --max-redirs 0 https://saas.com/tenant
# HTTP/2 308
# location: https://mydomain.com/blog ← points back at me = loop confirmed
curl -I --max-redirs 0 https://proxy.saas.dev/tenant
# HTTP/2 200 ← suitable as a proxy destination
Most SaaS vendors offer a separate host for custom-domain tenants that does not canonicalize. Point the rewrite there.
// next.config.mjs
async rewrites() {
return {
afterFiles: [
{ source: "/blog", destination: "https://proxy.saas.dev/tenant" },
{ source: "/blog/:path*", destination: "https://proxy.saas.dev/tenant/:path*" },
],
};
}
If a sibling rule in the same config file already works, its destination host is usually the answer. Read the neighbouring lines instead of staring only at the broken rule.
Trade-offs
- Rewrite (proxy): the path is unified under your domain, so SEO, cookies, and analytics stay on one origin. In exchange, all traffic passes through your server — added latency and cost — and you inherit the upstream's headers and status codes.
- Subdomain CNAME delegation (
blog.mydomain.com→ SaaS): zero proxy cost and no loop, but the content lives on a separate host, splitting cookies and analytics. - Redirect: simplest of all, but the SaaS address is exposed to users.
When not to use it
- The upstream canonicalizes and the vendor offers no proxy-only host. Give up on the rewrite and delegate a subdomain instead.
- Proxying site-wide resources such as
/robots.txtor/sitemap.xmlto a sub-service. The blog's robots file would override the whole site's indexing policy. - The upstream issues auth cookies for its own domain — through a proxy the cookie domain no longer matches and sessions break.
Common mistakes
- Reading a rewrite as a redirect and missing that upstream 3xx responses pass through. The vast majority of loops start here.
- Pasting someone else's config snippet wholesale. You needed one destination host; applying the whole snippet silently deletes your existing catch-all rewrites, image, and bundler settings, taking the entire site down.
- Moving the rule to
beforeFilesfor no reason. With no local route on the same path the behaviour is identical toafterFiles; you only enlarge the diff and invite precedence bugs. - Being fooled by browser caching. A 308 is permanent and gets cached. Verify the fix in a private window or with
curl.
Related concepts
- ssrf-url-fetch-proxy-guard — validating destinations when the server fetches someone else's URL
- locale-aware-i18n-routing — the layer where path routing and rewrites overlap
- etag-conditional-request-304 — conditional responses through a proxy