Fix: Next.js middleware not triggering
middleware.ts silently not running is almost always file location, a matcher excluding the route, or an Edge-incompatible import — checked in order.
middleware.ts running with zero console output and zero effect on requests
has three unrelated causes, and they fail silently in the same way — no error,
no warning, the request just passes through as if the file did not exist.
Check them in this order: file location, matcher config, then runtime
compatibility. Each one rules out roughly a third of real cases.
Check 1: the file is in the wrong place#
Middleware only runs from a single, specific location — middleware.ts
(or .js) directly inside src/ if you use a src directory, otherwise
directly inside the project root, next to package.json. It is not
matched anywhere else, including app/middleware.ts or a nested folder:
✅ my-app/middleware.ts (no src dir)
✅ my-app/src/middleware.ts (with src dir)
❌ my-app/app/middleware.ts (never runs)
❌ my-app/src/app/middleware.ts (never runs)There is no build error for a misplaced file — Next.js simply never registers it as middleware. If moving the file to the correct location makes it start running, this was the entire bug.
Check 2: the matcher config excludes your route#
Middleware runs on every route by default unless you export a config
object with a matcher. A matcher that is too narrow — or written for a
different route shape — silently skips the requests you're testing:
// middleware.ts
export const config = {
matcher: '/dashboard/:path*', // only /dashboard/... requests hit this
};
export function middleware(request) {
console.log('middleware ran for', request.nextUrl.pathname);
}Testing / or /login against this config will never log anything —
correctly, by the matcher's own rule. Two matcher mistakes cause most
"it just doesn't run" reports:
// ❌ matches only the literal path "/dashboard", not /dashboard/settings
matcher: '/dashboard'
// ✅ the :path* segment matches the exact route and everything under it
matcher: '/dashboard/:path*'// ❌ forgot the API/static exclusions — middleware now runs on every
// _next/static and _next/image request too, which is usually not intended
// (this doesn't stop your route from matching, but it's the inverse bug —
// worth checking if middleware seems to run "for everything")
export const config = { matcher: '/:path*' };
// ✅ the documented catch-all pattern that excludes framework internals
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};If you need multiple, independent route groups, matcher also accepts an
array — each entry is checked independently, so one overly strict pattern does
not block the others:
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*', '/api/protected/:path*'],
};Check 3: an import that fails on the Edge runtime#
Middleware runs on the Edge runtime, not Node.js, even though it is a
.ts file sitting next to your Node-only code. If middleware.ts imports
something that depends on Node APIs — fs, pg, most database drivers,
crypto in its Node form — the Edge bundler can fail to build the function,
and depending on your deployment target this can surface as the middleware
being skipped rather than a build error, especially with Cloudflare's Edge
runtime constraints.
// ❌ pg speaks raw TCP — not available on the Edge runtime
import { Pool } from 'pg';
export function middleware(request) {
const pool = new Pool(); // fails to run on the Edge
}// ✅ read a cookie/header directly — no database round-trip in middleware
export function middleware(request) {
const token = request.cookies.get('session')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
}If middleware genuinely needs to check something a database holds (a
feature flag, a ban list), call an HTTP endpoint with fetch — the Edge
runtime supports fetch natively — instead of importing a driver that
expects a socket:
export async function middleware(request) {
const res = await fetch(new URL('/api/session-check', request.url), {
headers: { cookie: request.headers.get('cookie') ?? '' },
});
if (!res.ok) return NextResponse.redirect(new URL('/login', request.url));
}Verifying the fix#
- Confirm the file path with
ls middleware.ts src/middleware.ts— exactly one should exist, at the correct level. - Add a
console.log(request.nextUrl.pathname)as the first line and hit the exact route you expect to match; server logs (not the browser console) are where Edge middleware output appears. - Temporarily remove the
config.matcherexport entirely — middleware then runs on every route. If it starts firing, the matcher pattern was the bug; restore a corrected pattern rather than leaving the matcher off.
The matcher must be a build-time constant, not a variable#
A less obvious cause: Next.js reads the matcher value by statically analyzing the
config export at build time, before any code actually runs — it does not evaluate
matcher as a normal JavaScript expression the way the rest of the file executes.
That means a matcher built from a runtime value looks syntactically fine, compiles
without error, and then behaves as if the field were empty or wrong:
// ❌ process.env is not resolved when the matcher is statically parsed
export const config = {
matcher: process.env.PROTECTED_ROUTE ?? '/dashboard/:path*',
};
// ❌ same problem — a matcher built from a runtime array
const routes = getProtectedRoutes();
export const config = { matcher: routes };
// ✅ a literal string or array, known at build time
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*'],
};This is why a matcher that reads correctly in a local console.log still fails to
restrict middleware once deployed — the build step doesn't run the module to inspect
config, it parses the export declaration, so only literal strings, arrays, and
object shapes are picked up. If the set of protected routes genuinely needs to change
per environment, keep the matcher as a permissive constant (or /:path* with the
standard static-asset exclusion) and do the environment-specific narrowing inside the
middleware/proxy function body instead, where request.nextUrl.pathname can be
checked against a value read from process.env at request time. The broader gotcha of
values that look identical locally and on a deployed build — because one resolves at
build time and the other at request time — is covered in
Next.js Environment Variables Undefined on Vercel? Fix.
Further reading: Next.js Redirect from / to another page, Next.js Query String Params: searchParams + useRouter.
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Related fixes & guides
- Fix: NextRouter was not mounted
- Fix: Event handlers cannot be passed to Client props
- cookies() Returns a Promise in Next.js 15/16: Fix
- Next.js 15 Middleware: Auth, Rate Limiting & Edge Guide
- Next.js Server Actions with Supabase: Complete Guide
- Next.js 16 Removed next lint: ESLint 9 Migration
- Fix Next.js revalidatePath Not Working in Server Actions
- Fix: Next.js proxy.js matcher not working for static assets
- Fix "cookies() should be awaited" Error in Next.js 15
- Fix Error: NEXT_REDIRECT logged in a Route Handler catch

