Middleware feels like the cleanest way to gate an entire section of a Next.js app behind auth, one file, one config, covering everything under a path. That confidence is exactly the problem, because the matcher config that's supposed to define "everything under this path" has a few specific gaps that don't show up until someone hits a nested route that quietly skipped the check entirely.
The Setup That Looks Complete
// middleware.ts
import { NextResponse } from 'next/server';
import { getToken } from 'next-auth/jwt';
export async function middleware(request: NextRequest) {
const token = await getToken({ req: request });
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: '/dashboard/:path*',
};
This reads as "protect everything under /dashboard," and for the routes most people test during development, /dashboard, /dashboard/settings, /dashboard/analytics, it works exactly as expected. The gap shows up specifically with route groups and parallel/intercepting route conventions, which don't map onto the URL path the way the folder structure visually suggests they do.
Where It Actually Breaks
app/
├── (dashboard)/
│ ├── dashboard/
│ │ └── page.tsx → /dashboard
│ └── settings/
│ └── page.tsx → /settings (NOT /dashboard/settings!)
Route groups, the parenthesized folders, don't appear in the URL at all. A page at app/(dashboard)/settings/page.tsx is served at /settings, not /dashboard/settings, even though it visually sits inside what looks like your dashboard's folder in the codebase. A matcher: '/dashboard/:path*' pattern never matches /settings, because as far as the URL is concerned, it was never under /dashboard to begin with.
This is exactly the kind of structural confusion that's easy to carry forward from a folder structure that leans on route groups for layout purposes, the same route groups I use throughout the Next.js templates on pixelanas.com, without re-checking that every protected page's actual URL still falls inside the matcher pattern protecting it, because the folder nesting and the URL nesting have quietly diverged.
A Second, Subtler Gap: Matcher Patterns and Encoded Characters
Next.js's own documentation for the matcher config notes that values must be literal strings or simple patterns, matcher patterns don't support arbitrary regex features, and certain edge cases around encoded path segments or unusual characters in dynamic segments can fail to match in ways that aren't obvious from reading the pattern itself. A route like /dashboard/reports/q1%2F2026 (an encoded slash inside a dynamic segment) can behave unpredictably against a pattern that looks like it should obviously cover it.
Why This Doesn't Get Caught
Testing auth-gated routes usually means manually clicking through the main pages of a protected section, the ones directly nested under the obvious path, and confirming the redirect works. Pages that technically live in the same conceptual section of your app but sit outside the matched URL pattern because of a route group, or a page that was added later and happens to resolve to a URL slightly outside what the matcher covers, simply never get manually tested against the auth gate, because nobody thinks to check a URL that doesn't look like it needs checking.
The Fix: Verify Matcher Coverage Against Actual Resolved URLs, Not Folder Structure
Don't trust the matcher pattern based on how your app/ folder looks. Check it against the actual resolved URL of every page you intend to protect:
export const config = {
matcher: [
'/dashboard/:path*',
'/settings/:path*', // explicitly added once the real URL was checked
'/billing/:path*',
],
};
A more defensive pattern for anything genuinely sensitive is inverting the logic entirely, protect everything by default, and explicitly exclude the few public routes, rather than trying to enumerate every protected path and hoping the list stays complete as the app grows:
export const config = {
matcher: [
'/((?!login|signup|api/public|_next/static|_next/image|favicon.ico).*)',
],
};
This pattern protects everything except the explicitly named public routes, which means a newly added page under a route group, or anywhere else, is protected by default rather than silently falling outside coverage until someone notices.
The Rule
A middleware matcher pattern should be verified against the app's actual resolved URLs, not assumed from how the app/ folder is visually organized. Route groups, parallel routes, and intercepting route conventions all affect the URL in ways that don't match the folder nesting, which is exactly where a matcher pattern that looks complete quietly isn't.
Get the templates: https://pixelanas.gumroad.com
Go check your own middleware matcher against the real URLs of every page it's supposed to protect, not the folder structure. If any of them use route groups, this is worth double-checking today. Drop what you find in the comments.
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)