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 tom.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 towww.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 openhttps://www.example.com/store?id=ABC
It redirects me tohttps://m.example.com/store
→?id=ABCis GONE. -
On desktop:
I openhttps://m.example.com/store?id=ABC
It redirects me tohttps://www.example.com/store
→ again,?id=ABCis 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.comactually lives atm.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/storeinstead ofm.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-storeandVary: User-Agentto 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=ABC → m.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.):
-
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?
-
On Vercel, do I ALSO need to send
Vary: User-Agent/Cache-Control: no-storeon 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? -
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?
-
-
For people already on Next.js 16’s new
proxy.tsmodel instead ofmiddleware.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.