Skip to content

The proxy matcher that also matches your static assets

A matcher like "/(.*)" runs auth on every CSS file, image and font. The symptoms are an unstyled site, a redirect loop, or a surprising invocation bill.

Clerk4 min readships at docs/solutions/clerk/the-matcher-that-ate-your-static-assets.md

Tags: clerk · nextjs · proxy · middleware · matcher · performance

You add authentication, deploy, and the site renders with no CSS. Or fonts

  1. Or the sign-in page reloads forever. Or everything works and the hosting bill for function invocations has quietly tripled.

All four are the same root cause: the proxy (Next.js 16's renamed middleware) is running for requests that are not pages.

What the matcher actually controls

Without a matcher, the proxy runs on every request: _next/static chunks, _next/image optimisations, files in public/, favicons, the lot.

That is rarely what anyone means. Three ways it goes wrong:

Redirect on assets. Auth logic that redirects unauthenticated requests to /sign-in will redirect the request for main.css too. The browser gets an HTML document where it asked for a stylesheet, refuses it because of the MIME type, and renders unstyled. The Network tab shows a 307 on a .css file, which is the tell.

Redirect loop. If /sign-in itself is matched and treated as protected, an anonymous visitor is redirected to a page that redirects them again. Chrome stops after twenty hops with ERR_TOO_MANY_REDIRECTS.

Cost and latency. Every matched request is a function invocation. A page with forty assets is forty-one invocations instead of one, each adding a few milliseconds to a file that would otherwise have been served straight from the CDN cache.

The matcher that works

export const config = {
  matcher: [
    // Everything except Next internals and anything that looks like a file.
    "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
    // ...but always run for API routes, which have no extension.
    "/(api|trpc)(.*)",
  ],
};

Reading it in parts:

  • _next excludes build output and image optimisation.
  • [^?]*\.(...) excludes paths whose last segment contains a known file extension, but only before a ?, so a page URL with a query string that happens to contain a dot is still matched.
  • js(?!on) excludes .js while keeping .json matched, because a JSON endpoint usually does need auth.
  • The second pattern re-includes /api and /trpc, which the first pattern excludes for any request that happens to look file-ish.

Two things to know about matchers generally: they must be statically analysable (build the array literally, never from a variable or a function call, because Next reads them at build time) and each entry must start with /.

Then decide what is public

The matcher decides where the proxy runs. It does not decide what is protected. That is the route matcher inside:

const isPublicRoute = createRouteMatcher([
  "/",
  "/sign-in(.*)",
  "/sign-up(.*)",
  "/api/webhooks/(.*)",
  "/pricing",
  "/blog(.*)",
]);

export const clerkProxy = clerkMiddleware(async (auth, request) => {
  if (isPublicRoute(request)) return;
  await auth.protect();
});

Default-deny is the right shape: forgetting to add a marketing page to the public list is a redirect someone reports in ten seconds; forgetting to add a route to a protected list is a leak nobody reports at all.

Note the (.*) suffixes. "/sign-in" alone does not match /sign-in/factor-one, which Clerk's own flow navigates to: that is the second most common redirect loop in a Clerk app.

And note the webhook. It must be public: the sender has no session, and it authenticates with a signature instead.

Debugging a live one

Add a log line at the top of the proxy and watch what comes through:

console.log("[proxy]", request.nextUrl.pathname);

If you see /_next/static/chunks/... or /logo.svg, the matcher is too broad. If you see nothing for a route that throws auth() errors, the matcher is too narrow: auth() only works where clerkMiddleware ran.

In the Network tab, sort by type and look for a 307 on anything that is not a document. That is the unstyled-site bug in one glance.

Remove the log line before committing. A per-request log on every page is noise in production and a cost in a hosted log pipeline.

Checking your work

  • Load a signed-out public page: exactly one proxy invocation in the log, for the document.
  • View source and confirm the CSS request returns 200 with content-type: text/css.
  • Hit a protected page signed out: one redirect, to /sign-in?..., and no second hop.
  • Hit /sign-in directly signed out: it renders, no redirect.
  • POST to /api/webhooks/clerk with a garbage body: 400 from your handler, not a 307 from the proxy.