This is the third article in Real Coding Problems, Simple Fixes.
This one is about a problem that can waste hours because the app looks like it is doing the right thing:
Protect private pages and redirect logged-out users to
/login.
But then the browser shows:
ERR_TOO_MANY_REDIRECTS
or your Next.js app keeps sending users back to the login page again and again.
That is usually a Next.js middleware redirect loop.
The cause is often simple:
Your middleware is protecting the login page too.
Let's fix it with clear code.
What We Are Fixing
If you are dealing with this bug, it usually looks like one of these problems:
Next.js middleware redirect loopNext.js login page keeps redirectingERR_TOO_MANY_REDIRECTS Next.jsNextAuth middleware redirect loopNext.js middleware matcher exclude login pageNext.js protected routes middleware
We will use a simple auth-token example first, then talk about NextAuth/Auth.js after.
The Real Problem
Imagine your app has these routes:
/login
/dashboard
/settings
/api/auth/login
You want this behavior:
Logged out user opens /dashboard
Redirect to /login
Logged out user opens /login
Show the login page
Logged in user opens /dashboard
Show the dashboard
That sounds straightforward.
So you write middleware to check whether the user has an auth token.
The Broken Middleware
Here is a common broken version:
import { NextResponse } from "next/server";
export function middleware(request) {
const token = request.cookies.get("auth_token")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
This looks reasonable at first.
If there is no token, send the user to /login.
But there is a hidden problem.
This middleware runs for more routes than you think.
So the request flow becomes:
User opens /dashboard
No token found
Redirect to /login
Browser opens /login
Middleware runs again
Still no token found
Redirect to /login again
Browser opens /login again
Middleware runs again
Still no token found
Redirect to /login again
That is the loop.
The login page can never load because the middleware keeps redirecting it to itself.
The Plain-English Explanation
Middleware runs before the page is rendered.
That makes it useful for auth checks, redirects, headers, rewrites, and request decisions.
But it also means you need to be specific.
If your middleware says:
Every logged-out request must go to
/login
then /login itself also becomes a logged-out request.
So the app gets stuck.
The fix is not complicated:
Do not run auth protection on public routes.
Your public routes usually include:
/login/register/forgot-password/api/auth- static files
- images
- favicon
- auth callback routes
Fix 1: Add Public Route Checks
The first fix is to define which routes are public.
import { NextResponse } from "next/server";
const publicRoutes = ["/login", "/register", "/forgot-password"];
function isPublicRoute(pathname) {
return publicRoutes.some((route) => pathname.startsWith(route));
}
export function middleware(request) {
const { pathname } = request.nextUrl;
const token = request.cookies.get("auth_token")?.value;
if (isPublicRoute(pathname)) {
return NextResponse.next();
}
if (!token) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("from", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
Now the request flow is different:
User opens /dashboard
No token found
Redirect to /login?from=/dashboard
Browser opens /login
/login is public
Show login page
That small public route check breaks the loop.
Fix 2: Use matcher to Protect Only Private Routes
Another good fix is to tell Next.js exactly where the middleware should run.
If only /dashboard and /settings need protection, the middleware should not run everywhere.
import { NextResponse } from "next/server";
export function middleware(request) {
const token = request.cookies.get("auth_token")?.value;
if (!token) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("from", request.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*"],
};
This is often cleaner.
With this setup:
/dashboard -> middleware runs
/dashboard/team -> middleware runs
/settings -> middleware runs
/login -> middleware does not run
/api/auth/login -> middleware does not run
For many apps, this is the simplest and safest solution.
Which Fix Should You Use?
Use matcher when you have a clear list of private route groups.
Example:
/dashboard
/account
/admin
/settings
Use public route checks when your app has more complex rules.
Example:
Some pages are public
Some pages are private
Some pages depend on role
Some pages depend on onboarding status
You can also combine both:
- use
matcherto avoid static files and unrelated routes - use route checks inside middleware for auth-specific decisions
Fix 3: Avoid Redirecting Logged-In Users Back to Login
There is another common issue.
After login, the user visits /login again and still sees the login page.
Usually, logged-in users should be redirected away from auth pages.
You can handle that like this:
import { NextResponse } from "next/server";
const authRoutes = ["/login", "/register"];
const privateRoutes = ["/dashboard", "/settings"];
function matchesRoute(pathname, routes) {
return routes.some((route) => pathname.startsWith(route));
}
export function middleware(request) {
const { pathname } = request.nextUrl;
const token = request.cookies.get("auth_token")?.value;
const isAuthRoute = matchesRoute(pathname, authRoutes);
const isPrivateRoute = matchesRoute(pathname, privateRoutes);
if (isAuthRoute && token) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
if (isPrivateRoute && !token) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("from", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
Now the rules are clear:
Logged-out user on /dashboard -> send to /login
Logged-out user on /login -> allow
Logged-in user on /login -> send to /dashboard
Logged-in user on /dashboard -> allow
That is the mental model you want.
Fix 4: Exclude API Routes and Static Files
If your middleware runs too broadly, it can also touch routes it should ignore.
For example:
/_next/static
/_next/image
/favicon.ico
/api/auth/login
/api/auth/callback
You usually do not want your page-auth middleware interfering with those.
Here is a common matcher pattern:
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico).*)",
],
};
This tells middleware to run on most pages, but skip API routes and common Next.js asset paths.
Be careful with this pattern though.
If your login page is still inside that matched group, you still need a public route check inside middleware.
That is why I prefer being explicit for beginner-friendly auth:
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*", "/account/:path*"],
};
It is easier to reason about.
NextAuth/Auth.js Redirect Loop Note
If you use NextAuth or Auth.js, redirect loops usually come from one of these problems:
- Your custom sign-in page is also protected by middleware.
- Your middleware does not know about your custom
signInpage. - Your callback or auth API route is being matched accidentally.
- The user is redirected after login to a route that immediately fails another middleware rule.
For a custom sign-in page, make sure your auth config and middleware agree about the sign-in route.
Example idea:
export const authOptions = {
pages: {
signIn: "/login",
},
};
And make sure /login is not treated like a protected page.
If your app uses a matcher like this:
export const config = {
matcher: ["/dashboard/:path*"],
};
then /login will not be protected by that middleware.
That alone fixes many redirect loops.
Common Mistakes
Mistake 1: Matching Every Route
This is risky:
export const config = {
matcher: ["/:path*"],
};
It can work, but only if your internal route logic is careful.
If you are new to middleware, start by matching only private sections.
Mistake 2: Redirecting to the Same URL
Before redirecting, think:
Am I already on the page I am redirecting to?
If the user is already on /login, do not redirect them to /login.
Mistake 3: Protecting Auth Callback Routes
Auth libraries often need callback routes to finish login.
If middleware blocks those routes, login may succeed with the provider but fail when the app receives the callback.
Watch routes like:
/api/auth/callback
/api/auth/session
/auth/callback
The exact paths depend on your auth library.
Mistake 4: Forgetting Role-Based Redirect Loops
Redirect loops are not only about login.
They can also happen with roles.
Example:
User opens /admin
Middleware says user is not admin
Redirect to /dashboard
Middleware checks /dashboard
Onboarding is incomplete
Redirect to /onboarding
Middleware checks /onboarding
Some rule sends user back to /dashboard
The fix is the same:
Make every redirect rule clear, and make sure the destination route is allowed for that user state.
A Simple Debugging Checklist
When your Next.js login page keeps redirecting, check this:
- Does middleware run on
/login? - Does middleware run on
/api/auth? - Does your matcher include too many routes?
- Are you redirecting to the same route you are already on?
- Does your custom sign-in page match your auth config?
- Are callback routes excluded?
- After login, are you redirecting to a route the user is actually allowed to access?
If you are stuck, add temporary logging:
export function middleware(request) {
console.log("middleware path:", request.nextUrl.pathname);
return NextResponse.next();
}
Then reload the page and watch which routes middleware is touching.
The path list usually reveals the problem quickly.
The Mental Model
Middleware redirect bugs become easier when you think in flows.
Do not only ask:
Is the user logged in?
Also ask:
Which route is the user trying to open?
Is that route public or private?
If I redirect, will the destination route be allowed?
Will middleware run again on the destination route?
That last question is the important one.
Because every redirect creates another request.
And middleware can run again on that new request.
Final Takeaway
A Next.js middleware redirect loop usually happens because the middleware is too broad.
Most commonly:
/dashboard redirects to /login
/login also runs middleware
/login redirects to /login again
The practical fix:
Protect only private routes
+ Keep login/register pages public
+ Exclude auth API and callback routes
+ Use matcher carefully
+ Never redirect a route to itself
Once your routes are separated into public, auth-only, and private groups, the loop becomes much easier to avoid.
Publishing SEO Notes
SEO title:
Fix Next.js Middleware Redirect Loop: Protect Routes Without Breaking Login
Meta description:
Learn why a Next.js middleware redirect loop happens, why login pages get accidentally protected, and how to fix protected routes with matcher and public route checks.
Suggested slug:
fix-nextjs-middleware-redirect-loop-protected-routes
Primary keyword:
Next.js middleware redirect loop
Secondary keywords:
Next.js auth redirect loop
Next.js login page keeps redirecting
ERR_TOO_MANY_REDIRECTS Next.js
NextAuth middleware redirect loop
Next.js middleware matcher exclude login page
Next.js protected routes middleware
These notes are for publishing. You can keep them out of the visible article body if your CMS already has dedicated SEO fields.
Suggested Internal Links
- Previous article:
Fix Next.js Hydration Error with localStorage - Related topic idea:
Fix Axios Refresh Token Infinite Loop in React and Node.js - Portfolio CTA:
I write practical engineering guides and build full-stack products. See more at amrishkhan.dev.
References
- Next.js docs: Middleware
- Next.js docs: File-system convention: middleware
- NextAuth docs: Next.js middleware configuration
- Next.js docs: Redirecting
Top comments (0)