GetLaunchpad
Back to blog
5 min read

How Next.js middleware works (and how to use it correctly)

Middleware runs at the edge before any page renders — but Next.js 16 renamed the file, the matcher pattern is easy to get wrong, and a misconfigured config export silently breaks everything. Here's how to set it up correctly with Clerk.

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:

Routes that should stay public:

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:

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

Share this article:Share on X

Ready to ship faster?

GetLaunchpad gives you everything covered in this guide — pre-configured, tested, and production-ready. Skip the setup and focus on your product.

Get the boilerplate →

More articles