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.

Mahdi Ben RhoumaMahdi Ben Rhouma · Updated

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:

plaintext
✅ 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:

ts
// 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:

ts
// ❌ 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*'
ts
// ❌ 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:

ts
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.

ts
// ❌ 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
}
ts
// ✅ 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:

ts
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#

  1. Confirm the file path with ls middleware.ts src/middleware.ts — exactly one should exist, at the correct level.
  2. 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.
  3. Temporarily remove the config.matcher export 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:

ts
// ❌ 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.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Related fixes & guides