Middleware in Next.js – The Right Way
When you start working with middleware in Next.js, it feels easy enough:
add a couple of redirects, protect some routes, done.
But here’s the problem: most developers (including past me) end up writing middleware like this:
export async function middleware(request: NextRequest, user: User | null) { const { pathname } = request.nextUrl; if (pathname.startsWith("/dashboard")) { if (!user) { return NextResponse.redirect(new URL("/login", request.url)); } } else if (pathname.startsWith("/onboarding")) { if (!user) { return NextResponse.redirect(new URL("/login", request.url)); } } if (pathname.startsWith("/login")) { if (user) { return NextResponse.redirect(new URL("/dashboard", request.url)); } } else if (pathname.startsWith("/signup")) { if (user) { return NextResponse.redirect(new URL("/dashboard", request.url)); } } return NextResponse.next(); }
Even though this works fine at a first glance. But there's a big problem in this approach.
As soon as you add more routes (e.g. /profile
, /settings
, /forgot-password
, etc.)... your file turns into a giant mess of if/else
.
And it will only become worse as your project scales.
Not fun, not scalable, and definitely - not clean.
Think in Rules, Not Conditions
Instead of hardcoding every redirect, we must only define conditions.
Think of a middleware as of a traffic cop.
The cop doesn't care who you are. It only checks where you’re going and whether you’re allowed to be there.
So let’s describe our rules in a structured way.
Step 1: Define Route Groups
We’ll split our routes into two simple groups:
const PROTECTED_ROUTES = ["/dashboard", "/onboarding"]; const AUTH_ROUTES = ["/login", "/signup"];
- Protected routes: only logged-in users can access them.
- Auth routes: only logged-out users should see them.
Already, much cleaner than writing 20 if checks.
Step 2: Describe Rules
Now let’s create a type for rules:
interface RouteRule { routes: string[]; condition: (user: User | null) => boolean; redirect: string; }
Each rule says:
- Which routes it applies to
- The condition that triggers a redirect
- Where to redirect to
Step 3: Write the Rules
Here’s what our two rules look like:
const routeRules: RouteRule[] = [ { routes: PROTECTED_ROUTES, condition: (user) => !user, // if no user, redirect redirect: "/login", }, { routes: AUTH_ROUTES, condition: (user) => !!user, // if logged in, redirect redirect: "/dashboard", }, ];
That’s it. Add more groups? Just add more objects.
No need to touch the logic again.
Step 4: Apply Rules in Middleware
Now we loop through the rules:
export class Middleware { private routeRules = routeRules; async handle(request: NextRequest, user: User | null) { const { pathname } = request.nextUrl; for (const rule of this.routeRules) { const isMatch = rule.routes.some((r) => pathname.startsWith(r) ); if (isMatch && rule.condition(user)) { return NextResponse.redirect( new URL(rule.redirect, request.url) ); } } return NextResponse.next(); } }
No clutter. No chaos. Just clear logic.
Why This Is the “Right Way”
- Scalable – Add new routes in seconds
- Readable – No jungle of if/else
- Reusable – One central place for rules
- Extensible – Can be expanded for roles, permissions, etc.
- SOLID compliant.
Middleware must feel like a middleware, not a nightmare.
Final Thoughts
The trick is simple:
Stop hardcoding conditions. Start thinking in rules.
This pattern will save you headaches, especially if your app has authentication and a growing set of protected pages.
If you found this interesting, comment, and let's have a talk!
Top comments (0)