Redirect between multiple subdomains without losing the query string?

I’m trying to ship an old-school www. vs m. split in Next.js/Vercel and I’m running into something that feels insane.

Setup:

  • Next.js (App Router), deployed on Vercel.

  • Two public subdomains:

    • www.example.com (desktop)

    • m.example.com (mobile)

Business requirement:
The query string encodes which branch/location to load. It’s not cosmetic. Example:

  • https://www.example.com/store?id=JAKARTA-UTARA

  • https://m.example.com/store?id=JAKARTA-UTARA

That ?id=... param is critical. Without it, the page can’t resolve which branch to show, and it basically renders empty. This is not “UTM tracking,” this is actual routing.

The behavior I NEED (non-negotiable):

  • If a MOBILE user hits www.example.com/``..., I must redirect them to m.example.com/``..., and the query string MUST survive:
    www.example.com/store?id=JAKARTA-UTARA
    → (mobile redirect) →
    m.example.com/store?id=JAKARTA-UTARA

  • If a DESKTOP user hits m.example.com/``..., I must redirect them to www.example.com/``..., and again the query string MUST survive:
    m.example.com/store?id=JAKARTA-UTARA
    → (desktop redirect) →
    www.example.com/store?id=JAKARTA-UTARA

Why two-way matters:

  • Field staff on phones share m.example.com/... links in WhatsApp.

  • Someone on a laptop later opens that same link.

  • That laptop view must land on www.example.com/... with the SAME ?id=... param intact because that param selects the branch.

  • Reverse scenario also happens (desktop link opened on phone).

Here’s the problems:
In practice, the query gets dropped sometimes when switching subdomains.

Real behavior I’ve seen:

  • On mobile:
    I open https://www.example.com/store?id=ABC
    It redirects me to https://m.example.com/store
    ?id=ABC is GONE.

  • On desktop:
    I open https://m.example.com/store?id=ABC
    It redirects me to https://www.example.com/store
    → again, ?id=ABC is gone.

When that param dies, the entire branch lookup breaks, and the page looks empty/wrong. This is a business blocker, not just an SEO annoyance.

What I was doing before (the “canonical host per device” thing):

// initial middleware (bad)
const isMobile = /iphone|android|.../i.test(ua);
const canonicalHost = isMobile ? "m.example.com" : "www.example.com";

if (hostname !== canonicalHost) {
  url.hostname = canonicalHost;
  return NextResponse.redirect(url, 301 /* or 308 */);
}

Looked harmless. But here’s what actually happened:

  • Using 301 / 308 (permanent redirect) teaches the browser:
    “Oh, www.example.com actually lives at m.example.com,” or the other way around.

  • Browsers then start jumping across subdomains on their own in future visits, without even hitting my app again.

  • That cached jump is not guaranteed to forward the query string.

  • So I end up with m.example.com/store instead of m.example.com/store?id=ABC.

It feels like once the browser has cached a permanent cross-subdomain redirect, it will aggressively reapply it later, sometimes minus the query. And then my important param is just… gone.

It gets worse: because I was doing “mobile → m / desktop → www,” there’s a two-way redirect. That means Chrome/Safari can build two different cached rules depending on device. I’ve also seen what looks like edge/CDN reuse (desktop redirect logic reused for mobile or vice versa) if you don’t set Vary: User-Agent.

What I tried next (current approach):

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const mobileRegex =
  /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini|mobile/i;

export const config = {
  matcher: ["/((?!api|_next).*)"],
};

export function middleware(req: NextRequest) {
  const url = req.nextUrl;
  const { hostname, pathname, search } = url;

  // Detect device
  const ua = req.headers.get("user-agent") ?? "";
  const isMobile = mobileRegex.test(ua);

  // Rule:
  // - mobile should end up on m.example.com
  // - desktop should end up on www.example.com
  const expectedHost = isMobile
    ? "m.example.com"
    : "www.example.com";

  // Host mismatch? → redirect
  if (hostname !== expectedHost) {
    // MANUALLY build final URL so query always gets carried:
    // pathname = "/store"
    // search   = "?id=ABC" (or "" if no query)
    const redirectURL = `https://${expectedHost}${pathname}${search}`;

    // use 307 TEMPORARY, NOT 308/301
    const res = NextResponse.redirect(redirectURL, 307);

    // tell browser/CDN not to cache this as a permanent mapping
    res.headers.set("Cache-Control", "no-store, no-cache, max-age=0");
    res.headers.set("Pragma", "no-cache");
    res.headers.set("Expires", "0");

    // tell the edge that redirect depends on UA
    res.headers.set("Vary", "User-Agent");

    return res;
  }

  // already on the correct host for this device
  return NextResponse.next();
}

Why this version SHOULD be correct:

  • I’m using 307 (temporary) instead of 308/301 so the browser doesn’t “lock in” a permanent cross-domain redirect.

  • I’m manually constructing https://${expectedHost}${pathname}${search} so the querystring (?id=...) always gets appended.

  • I set Cache-Control: no-store and Vary: User-Agent to stop any CDN/edge layer from reusing a cached redirect from one device class for another.

I also changed my next.config.js redirects so they’re permanent: false (which means Next.js uses 307 instead of 308). I don’t want to feed the browser any more permanent (308) cross-subdomain rules ever again if I can help it.

Where I’m stuck:
Even after doing all of the above, I’m still seeing cases where the query string is missing after the subdomain flip — especially on devices that have previously seen a permanent redirect between www.example.com and m.example.com.

On a clean desktop incognito profile, it generally works.
On a device that “learned” an older 301/308 between those hosts, sometimes I still get:
www.example.com/store?id=ABCm.example.com/store (no ?id=ABC)
which completely breaks branch selection.

So I have a few questions for anyone who’s been through this in production (classic m. vs www. split, responsive is not allowed politically/internal-corp-style so you HAVE to keep m.):

  1. Is this basically a browser-cache poisoning problem once you’ve ever served a permanent 301/308 between those two subdomains? i.e. after that, the browser might silently “optimize” future redirects and drop query params before it even talks to your middleware again?

  2. On Vercel, do I ALSO need to send Vary: User-Agent / Cache-Control: no-store on the redirect response to make sure edge nodes don’t reuse a cached desktop redirect for mobile traffic (and vice versa)? Has anyone confirmed that actually stops cross-UA leakage?

  3. Did you end up moving this logic OUT of Next.js Middleware and into a true edge proxy (Cloudflare Worker, nginx, etc.) just so you can guarantee:

    • the first hop is always 307

    • the rewritten Location header ALWAYS includes original ?query

    • no browser ever learns a permanent cross-domain redirect that drops params?

  4. For people already on Next.js 16’s new proxy.ts model instead of middleware.ts:

    • Are you still doing this UA-based subdomain split there?

    • Are you still manually building https://$``{expectedHost}${pathname}${search}?

    • Are you still forcing 307 and sending Vary: User-Agent?

    • Or did you eventually give up and pick one canonical domain for everyone?

Important note: I am NOT looking for “just use responsive design and kill m.example.com.” I get it. I know that’s cleaner in 2025. I’m asking about the case where the business literally depends on a query param being preserved during the redirect because that query is the location/branch identifier.

If you’ve solved reliable two-way redirect between www.example.com and m.example.com WITHOUT losing the query string, please drop:

  • your final redirect code

  • which status codes you used

  • which headers you set

  • whether you had to purge old 308/301 behavior somehow (browser cache, Vercel project redirect settings, CDN rules, etc.)

Right now I can’t tell if I’m fighting:

  • browser-level cached permanent redirects,

  • Vercel edge caching,

  • or something wrong in my Middleware logic.

Any production war stories would be huge.