Middleware Is Not a Router: What We Learned Rebuilding Auth on the Edge
Next.js middleware looks like the perfect place for auth. Then your bundle balloons, your Edge runtime chokes on a Node API, and your login redirects loop. Here's how we untangled it.

We inherited a Next.js app last quarter where middleware.ts had quietly grown to 380 lines. It handled auth, locale detection, A/B test bucketing, a feature flag check against LaunchDarkly, and — somehow — image optimization fallbacks. Cold starts on the edge were spiking past a second, and the team was debugging a redirect loop that only triggered for users with expired refresh tokens on Safari. This is the post we wish we'd had before we started ripping it apart.
Why middleware feels like the right answer (and usually isn't)
Next.js middleware runs before a request hits a route. It's the only place in the App Router where you can rewrite, redirect, or set headers based on the incoming request before any rendering happens. So the instinct is: that's where auth goes. Check the cookie, decide if the user can see the page, redirect to /login if not.
That instinct is half right. Middleware is the correct place to make a coarse routing decision. It is the wrong place to make a fine-grained authorization decision, and it is a terrible place to put anything that touches a database, a Node-only SDK, or a third-party API with non-trivial latency.
Three constraints bite you in production:
- The Edge runtime is not Node. No
fs, nocrypto(the Node one — Web Crypto is fine), no native modules. Half the auth SDKs you've used assume Node. - Middleware runs on every matched request. Including prefetches, including RSC payload requests, including the static assets you forgot to exclude in your matcher.
- Bundle size directly impacts cold start. The edge function is shipped to every PoP. A 2 MB middleware bundle is a 2 MB cold start everywhere in the world.
The redirect loop that taught us the lesson
The bug we got paged for was simple to describe: users with an expired session cookie hit /dashboard, got redirected to /login?next=/dashboard, logged in, came back to /dashboard, and were redirected to /login again. Forever.
The middleware looked roughly like this:
// middleware.ts — the broken version
import { NextResponse, type NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
export async function middleware(req: NextRequest) {
const token = req.cookies.get('session')?.value;
if (!token) return redirectToLogin(req);
try {
await jwtVerify(token, SECRET);
return NextResponse.next();
} catch {
return redirectToLogin(req);
}
}
function redirectToLogin(req: NextRequest) {
const url = req.nextUrl.clone();
url.pathname = '/login';
url.searchParams.set('next', req.nextUrl.pathname);
return NextResponse.redirect(url);
}
export const config = {
matcher: ['/dashboard/:path*', '/login'],
};
Spot it? The matcher includes /login. After login, the server set a fresh cookie via a Server Action. But the client navigated to /dashboard using the existing RSC prefetch cache, which carried the old cookie state in a way that confused the middleware's view of the request. On top of that, the matcher was running on /login itself, and any request to /login with no token would try to redirect to /login — which it already was.
Two fixes, in order of importance:
Fix 1: Stop running middleware where it doesn't belong
The matcher should exclude /login, /api/auth/*, static assets, and the Next.js internals. The official recommendation is a negative lookahead:
export const config = {
matcher: [
// Run on everything except: _next, static files, login, auth API
'/((?!_next/static|_next/image|favicon.ico|login|api/auth).*)',
],
};
This alone cut our middleware invocations by about 60% in our logs. Every static asset request that previously triggered the function stopped doing so.
Fix 2: Don't trust the cookie, trust the verification
The other half of the loop was a stale RSC prefetch. The clean answer is to make the redirect target itself responsible for the final auth check, and have middleware only handle the obvious case of "no cookie at all". Fine-grained checks happen in the layout or page using cookies() and a server-side session lookup.
What middleware should actually do
After the cleanup, our middleware does exactly three things:
- Reject requests with no session cookie on protected paths (cheap, no I/O).
- Verify the JWT signature using
joseand Web Crypto (no network call). - Attach a header with the decoded subject so downstream route handlers don't re-verify.
Everything else — checking if the user's organization is active, whether they have a specific role, whether their seat is paid — happens in a server component or a Server Action, where you have the full Node runtime and can hit your database.
// middleware.ts — the version we ship
import { NextResponse, type NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
export async function middleware(req: NextRequest) {
const token = req.cookies.get('session')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', req.url));
}
try {
const { payload } = await jwtVerify(token, SECRET, {
algorithms: ['HS256'],
clockTolerance: 5,
});
const res = NextResponse.next();
res.headers.set('x-user-id', String(payload.sub));
return res;
} catch {
// Signature invalid or expired — clear and bounce
const res = NextResponse.redirect(new URL('/login', req.url));
res.cookies.delete('session');
return res;
}
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|login|api/auth).*)'],
};
Note what's not there: no database client, no Redis lookup, no fetch to your auth provider's userinfo endpoint. If you need any of those, do them in the layout.
Pushing the rest into the right layer
For anything that needs Node APIs or your database, a root layout for the protected segment is the natural home:
// app/(app)/layout.tsx
import { cookies, headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { getSessionUser } from '@/lib/auth/server';
export default async function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
const userId = (await headers()).get('x-user-id');
if (!userId) redirect('/login');
const user = await getSessionUser(userId);
if (!user || user.status !== 'active') {
(await cookies()).delete('session');
redirect('/login?reason=inactive');
}
return <UserProvider user={user}>{children}</UserProvider>;
}
This layout runs on the Node runtime by default, so getSessionUser can use Prisma, Drizzle, or whatever ORM you like. Middleware did the cheap signature check; the layout does the expensive correctness check; the two layers don't fight each other.
A word on Server Actions
Server Actions are not exempt from auth. A common mistake is assuming that because middleware protected the page, the actions on that page are also protected. They're not — Server Actions are POST endpoints that can be invoked directly. Every action that mutates data should re-read the session and re-check authorization. We wrote about the cost of treating actions like API routes on the blog recently; the auth angle is the same shape of problem.
Measuring whether it actually helped
Numbers from our cleanup, framed as ranges because every app is different:
- Middleware p95 dropped from ~180–220 ms to ~25–40 ms once the database call moved out.
- Cold-start bundle size went from ~1.8 MB to under 300 KB after we removed the LaunchDarkly SDK from the edge and called it from a server component instead.
- INP on authenticated pages improved by roughly 60–90 ms, mostly because we stopped issuing a middleware-driven redirect on every client-side navigation that the App Router was already handling.
The Core Web Vitals win was the surprise. We'd assumed middleware was "free" because it ran server-side, but a redirect during a soft navigation still costs the user a round trip.
The checklist we use now
Before anything lands in middleware.ts, it has to answer yes to all of these:
- Does it need to run before routing? (If it can run in a layout, it should.)
- Does it work with only Web APIs and Web Crypto?
- Is the total added bundle size under ~50 KB?
- Is it deterministic given only the request — no database, no third-party API?
- Have we excluded it from paths where it would cause a loop or run on assets?
If any answer is no, it belongs in a layout, a route handler, or a Server Action.
Where we'd start
If you're staring at a fat middleware.ts right now, don't rewrite it. Do two things this week: tighten the matcher to exclude _next, static assets, and your auth routes, and move the single most expensive call out of middleware into the nearest protected layout. Re-measure. You'll usually find that's 80% of the win, and the remaining cleanup becomes obvious once the noisy logs go quiet. If you want a second pair of eyes on an App Router migration or an edge performance audit, our web development team does this work as a fixed-scope engagement.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Font Loading in 2026: Why next/font Isn't Always the Answer
next/font is great until it isn't. A practical breakdown of when to use it, when to ship raw @font-face, and how to stop fonts from wrecking your LCP and CLS scores.

View Transitions in the App Router: The Good, the Janky, and the Layout Shifts
Cross-document view transitions finally landed in stable browsers and Next.js shipped a primitive for them. Here's what actually works in production, what still tears, and how to keep CLS honest.

Cache Tags in Next.js: How We Stopped Nuking the Entire CDN on Every Publish
A war story about how `revalidateTag` and a disciplined tagging scheme replaced our nightly full-cache purge — and the four gotchas we hit getting there in production.
