Next.js middleware is one of the most powerful features in the framework — and one of the most misunderstood. It runs at the edge, before any page renders, giving you a chance to redirect, rewrite, or reject a request before it ever reaches your application code. This guide covers exactly how it works, how to set it up correctly in Next.js 16, and the common mistakes that silently break it.
What middleware is and when it runs
Middleware executes on every incoming request that matches its config.matcher pattern — before the request reaches a route handler, server component, or static file. It runs in the Edge Runtime, which means it has access to the Request and Response objects but not Node.js APIs like fs or crypto.
The Edge Runtime is fast (cold starts measured in milliseconds, not seconds) and globally distributed on Vercel — your middleware runs in the same region as your users. The tradeoff is the restricted runtime: keep middleware lean and delegate heavy logic to route handlers.
The lifecycle of a request looks like this:
Incoming request
→ Middleware (edge, runs first)
→ Redirect / Rewrite / Continue
→ Route Handler or Server Component
→ Response sent to client
The proxy.ts rename in Next.js 16
This is the breaking change that catches most developers. In Next.js 16, the middleware file was renamed from middleware.ts to proxy.ts. Every existing tutorial, Stack Overflow answer, and library README still references middleware.ts. If you follow them and name your file middleware.ts, the framework silently ignores it — your protected routes will appear to work in development but be fully exposed in production.
Create proxy.ts at the root of your project, at the same level as the app/ directory:
your-project/
├── app/
├── lib/
├── proxy.ts ← correct location and filename
└── package.json
Using clerkMiddleware and createRouteMatcher
The cleanest way to protect routes is with Clerk's clerkMiddleware combined with createRouteMatcher. The matcher defines which paths require authentication; everything else stays public.
// proxy.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
const isProtected = createRouteMatcher([
"/dashboard(.*)",
"/api/user(.*)",
"/api/projects(.*)",
]);
export default clerkMiddleware((auth, req) => {
if (isProtected(req)) auth().protect();
});
export const config = {
matcher: ["/((?!_next|.*\..*).*)"],
};
createRouteMatcher accepts an array of path patterns. The (.*) suffix makes it match all sub-paths — so /dashboard(.*) matches /dashboard, /dashboard/settings, and /dashboard/projects/123.
auth().protect() short-circuits the request and redirects the user to your sign-in page (configured via NEXT_PUBLIC_CLERK_SIGN_IN_URL) if they are not authenticated. You never need to add auth checks inside the protected page components themselves — the middleware handles it.
Public vs protected route patterns
By default, any route not matched by your isProtected matcher is public. This is the right default — be explicit about what is protected rather than trying to allowlist public routes.
Common protected patterns:
/dashboard(.*) — the full dashboard, including all nested routes/api/stripe(.*) — Stripe checkout and portal endpoints (protect these to associate sessions with real users)/api/user(.*) — user profile and settings endpoints/api/projects(.*) — any business-logic API that operates on user data
Routes that should stay public:
/, /pricing, /blog(.*) — marketing pages/sign-in(.*), /sign-up(.*) — Clerk auth pages/api/stripe/webhook — Stripe sends unsigned requests here; Clerk auth would block them
Note that /api/stripe/webhookshould never be in your protected list. Stripe authenticates webhook requests with a signature header, not a Clerk session. If you add it to the protected matcher, Stripe's POST requests will be rejected as unauthorized.
Common mistakes
Forgetting to export config
The config export tells Next.js which requests to run middleware on. Without it, middleware either runs on nothing or runs on everything — including static files, which causes errors.
// Always include this export:
export const config = {
matcher: ["/((?!_next|.*\..*).*)"],
};
The regex /((?!_next|.*\\..*).*)/ skips Next.js internals (_next/static, _next/image) and files with extensions (.ico, .png, .css). This is the standard pattern — copy it verbatim rather than writing your own.
Matching too broadly
Using matcher: [“/(.*)”] without excluding _next paths and static files will run your middleware on every asset request, adding latency and potentially breaking image optimization. Stick to the standard exclusion pattern above.
Using Node.js APIs in middleware
Middleware runs in the Edge Runtime, not Node.js. Importing packages that use fs, path, crypto, or Buffer will cause a build error. If you need Node.js APIs, move that logic into a regular API route handler.
Naming the file middleware.ts instead of proxy.ts
Covered above, but worth repeating: in Next.js 16, the file must be named proxy.ts. No error is thrown for a misnamed file — it is simply ignored.
How to debug middleware
Middleware does not run in the browser, so you cannot use browser DevTools to debug it. Your options are:
- console.log in development — logs appear in your terminal (where
npm run dev is running), not in the browser console. - Return early with a debug response — temporarily return
new Response(“middleware hit”) at the top of your middleware function to confirm it is running at all. - Vercel Function Logs— in the Vercel dashboard, navigate to your deployment → Functions → select the middleware function → view real-time logs. This is the most useful tool for debugging production issues.
- Check the matcher— the most common reason middleware “doesn't work” is that the path doesn't match the
config.matcher pattern. Add a console.log(req.nextUrl.pathname) to verify the request is reaching the middleware function.
// Temporary debug middleware
export default clerkMiddleware((auth, req) => {
console.log("[middleware] path:", req.nextUrl.pathname);
if (isProtected(req)) {
console.log("[middleware] protecting route");
auth().protect();
}
});
The proxy.ts middleware with clerkMiddleware, createRouteMatcher, and the correct config export is already pre-configured in GetLaunchpad, a Next.js 16 SaaS boilerplate. Skip the setup and start building your product today.