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)