← All posts

A one-line fix for an infinite redirect loop between Vite, TanStack and Cloudflare

By ·

Deploy a Vite app under a non-root base — say base: "/example/" — and run it under alchemy dev. Every request becomes a redirect loop until the browser gives up.

The fix was one line. Finding it meant understanding that three separate tools were each doing exactly what they were designed to do.

The loop

  1. The browser requests /example/pricing
  2. Vite's base middleware rewrites req.url to /pricing — by design, so internal middleware sees paths without the base
  3. The Cloudflare dev plugin forwards /pricing to the Worker
  4. TanStack Start sees a URL missing its base and redirects to /example/pricing
  5. Go to step 1

Following redirects exhausted the browser's redirect limit. Every path 307'd to itself.

Nobody was wrong

This is what made it interesting. Vite strips the base deliberately — its middleware pipeline is supposed to work in base-relative terms. TanStack Start redirects deliberately — a URL missing its configured base should be corrected. The Cloudflare plugin forwards what it's given.

The bug lived in the handoff. Vite preserves the browser-facing URL on req.originalUrl precisely so that middleware which needs the real URL can recover it. The dev plugin wasn't reading it:

const url = new URL(req.url ?? "/", address);

So the Worker received a different pathname in development than the browser actually requested — which is the one thing a dev server must never do.

The fix

const url = new URL(req.originalUrl ?? req.url ?? "/", address);

That's it. /example/pricing?plan=pro now reaches the Worker unchanged, base path and query string intact, with the existing fallback preserved.

Cloudflare's own official Vite plugin already restores req.originalUrl before constructing the Worker request, for exactly this reason. The fix aligned this plugin with upstream behaviour rather than inventing anything.

Validating it

Before, against the unmodified plugin: /example/307 Location: /example/, and /example/pricing307 Location: /example/pricing. Self-redirects, both.

After: /302 Location: /example/, /example/200, /example/pricing200.

The lesson

When something loops between tools, the bug usually isn't inside any of them. It's at a boundary, in an assumption one side makes about what the other preserves. req.url versus req.originalUrl is a distinction you can ignore for years — until you mount an app somewhere other than the root.

Merged as alchemy-run/cloudflare-tools#86.