Route guards in Next.js middleware that don't loop on mid-range Android
A client's admin dashboard kept redirecting logged-in users back to the login page on mid-range Android phones over flaky 4G. The session cookie was present, the token was valid, but the middleware kept bouncing them. The issue was not the auth logic itself — it was how the middleware interacted with the request lifecycle under slow connections and aggressive browser caching.
Next.js middleware runs on the edge, which sounds fast until you realize it runs on every request, including static assets. The default `matcher` config in many tutorials catches everything, and that is where the first problem starts.
The matcher problem
A typical setup looks like this:
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};This looks correct but has a subtle gap. The negative lookahead excludes `_next/static`, but Next.js also serves chunks from `_next/data` for `getServerSideProps` pages and prefetch requests. On a slow connection, the browser prefetches routes aggressively, and those prefetch requests hit middleware. If your auth check makes a subrequest to verify a token, you end up with multiple round trips per navigation, and on a mid-range Android phone with 200ms latency, that adds up to a second or more of blocking time before the page even starts rendering.
We narrow the matcher to only the routes that actually need protection:
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*', '/settings/:path*'],
};This is more verbose, but it means middleware does not run on marketing pages, blog posts, or static assets. The tradeoff is that you have to update the matcher when you add new protected routes. We prefer this over the alternative — a single regex that silently catches too much.
Redirect loops from cookie timing
The redirect loop on Android turned out to be a cookie expiry check. The middleware read the cookie, parsed the JWT, and compared `exp` against `Date.now()`. On the edge, `Date.now()` is reliable, but the cookie itself was being set with `SameSite=Lax` and no `Secure` attribute on a domain that was served over HTTPS through a proxy that terminated TLS upstream.
On desktop Chrome, the cookie was sent. On mid-range Android Chrome, the browser sometimes dropped the cookie on prefetch requests because the TLS termination made the cookie appear insecure. The middleware saw no cookie, redirected to `/login`, and the login page — which was not in the matcher — rendered fine, but the user was already authenticated. They clicked login, got sent back to the dashboard, and the cycle repeated.
The fix was two parts. First, set cookies with `Secure: true` and `SameSite=Lax` explicitly, and make sure the proxy forwards the correct headers so the edge runtime sees the request as HTTPS:
res.cookies.set('session', token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/',
});Second, add a grace period to the expiry check. If the token expires in the next 30 seconds, treat it as valid for this request and let the page-level auth check handle the refresh. This avoids a race where the token expires between the middleware check and the page render.
What we do not do in middleware
n We do not verify tokens against a database or external service in middleware. That adds a round trip to every protected route. Middleware should check cookie presence and basic structure — is it there, is it expired, does it have the right claims. The full verification happens in the page or API route, where you have access to your database connection and can afford the latency.
We also do not use middleware for role-based access control beyond a simple prefix check. If admins need access to `/admin/*` and regular users do not, that is fine in middleware. But if you need to check whether a user has access to a specific project ID, do that in the page. Middleware does not have request body access, and parsing query params for authorization decisions leads to bugs.
The prefetch header check
One last thing that helped with the Android issue specifically. Next.js sends a header `Next-Router-Prefetch: 1` on prefetch requests. We skip the redirect for prefetch requests and let the page handle auth:
if (request.headers.get('Next-Router-Prefetch')) {
return NextResponse.next();
}This means a prefetch of a protected route does not redirect to login, which prevents the browser from caching a redirect response that would fire when the user actually navigates. The page-level check still runs on real navigation, so security is not compromised. We shipped this change and the redirect loop reports stopped within a week.