You add authentication, deploy, and the site renders with no CSS. Or fonts
- 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:
_nextexcludes 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.jswhile keeping.jsonmatched, because a JSON endpoint usually does need auth.- The second pattern re-includes
/apiand/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-indirectly signed out: it renders, no redirect. - POST to
/api/webhooks/clerkwith a garbage body: 400 from your handler, not a 307 from the proxy.