DEV Community

Mohamed Gado
Mohamed Gado

Posted on Originally published at mohamedgado.com on

Clerk auth in Next.js: from authMiddleware to proxy.ts and auth.protect()

In 2023 I built a Next.js 13 dashboard behind Clerk, and every route's protection lived in one file: authMiddleware in middleware.ts, deny by default. That code no longer runs on current versions. authMiddleware was replaced in @clerk/nextjs v5 by clerkMiddleware(), which protects nothing by default. Next.js 16 renamed middleware.ts to proxy.ts. And as of November 2026 Clerk deprecates gating routes in that file at all: each page, Route Handler and Server Function calls await auth.protect() itself.

This post walks the demo's real code from the 2023 version to the current one, and explains why the check moved.

What did the 2023 middleware do?

This is the whole of the demo's auth wiring, on next 13.4.7 and @clerk/nextjs 4.21:

// middleware.ts
import { authMiddleware } from '@clerk/nextjs'

export default authMiddleware({
  publicRoutes: ['/api/:path*'],
})

export const config = {
  matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
}
Enter fullscreen mode Exit fullscreen mode

authMiddleware was deny by default. Every path the matcher covered needed a session unless publicRoutes named it. The dashboard at / and the profile page at /profile had no auth code of their own:

// app/(dashboard)/profile/page.tsx
import { UserProfile } from '@clerk/nextjs'

export default function ProfilePage() {
  return (
    <main className='flex space-y-4 p-4'>
      <UserProfile />
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

The sign-in and sign-up pages are optional catch-all routes, app/(auth)/(routes)/login/[[...sign-in]]/page.tsx and register/[[...sign-up]], each rendering Clerk's <SignIn /> or <SignUp />. Two environment variables point Clerk at them:

NEXT_PUBLIC_CLERK_SIGN_IN_URL=/login
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/register
Enter fullscreen mode Exit fullscreen mode

That part has not changed. The catch-all segment is still how Clerk's components handle their own sub-steps (verification, factor-two) under one route.

Where did that setup leak?

Deny by default felt safe, and it mostly was. Two holes were sitting in that file anyway.

The matcher skipped any path with a dot in it. /((?!.*\\..*|_next).*) excludes a path when anything after the first slash contains a ., which was meant for favicon.ico and image files. A dynamic page like /u/[handle] visited as /u/john.doe also contains a dot, so the middleware never ran and the page rendered for anyone. Nothing in the demo had such a route, but nothing stopped the next one from being added. Clerk's current matcher skips only paths ending in a known static extension (css, js, png, woff2 and so on).

publicRoutes: ['/api/:path*'] made every API route public. That was deliberate, so webhooks could reach the app. But any Route Handler added later under /api that read user data had to check the session itself, and nothing reminded anyone to.

Both holes have the same shape. The protection was a list of URL patterns in one file, and the code it protected lived somewhere else. Clerk's migration guide names a third case that a path list cannot cover: Server Functions (including Server Actions) are called by ID, not by path. A Server Action defined in a protected folder and called from a public page arrives at the middleware as a request for the public page.

What replaced authMiddleware?

@clerk/nextjs v5 introduced clerkMiddleware(), imported from @clerk/nextjs/server. Clerk's upgrade guide states the primary change: it "does not protect any routes by default". A straight port of the demo looked like this, using createRouteMatcher() to list the protected paths:

// middleware.ts, @clerk/nextjs v5 and v6
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

const isProtected = createRouteMatcher(['/', '/profile(.*)'])

export default clerkMiddleware(async (auth, req) => {
  if (isProtected(req)) await auth.protect()
})
Enter fullscreen mode Exit fullscreen mode

Note the inversion. The 2023 file listed public routes; this one lists protected routes. A new page that nobody adds to the list ships public, which is the failure deny by default was meant to prevent.

How should a Clerk app protect routes on Next.js 16?

Clerk's current docs (last updated 10 November 2026) deprecate createRouteMatcher(). It still works and logs a one-time warning in development, and it will be removed in the next major version of @clerk/nextjs. The recommendation is to protect each server-side resource where it runs.

Next.js 16 renamed Middleware to Proxy, so the file is proxy.ts. The behaviour is the same and only the name changed. It keeps clerkMiddleware() with no auth logic, because Clerk still needs it to read the session:

// proxy.ts (middleware.ts on Next.js 15 and earlier)
import { clerkMiddleware } from '@clerk/nextjs/server'

export default clerkMiddleware()

export const config = {
  matcher: [
    // Skip Next.js internals and static files, unless found in search params
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    // Always run for API routes
    '/(api|trpc)(.*)',
    // Clerk's frontend API proxy path
    '/__clerk/(.*)',
  ],
}
Enter fullscreen mode Exit fullscreen mode

The check moves into the page:

// app/(dashboard)/profile/page.tsx
import { UserProfile } from '@clerk/nextjs'
import { auth } from '@clerk/nextjs/server'

export default async function ProfilePage() {
  await auth.protect()

  return (
    <main className='flex space-y-4 p-4'>
      <UserProfile />
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

Route Handlers and Server Functions get the same line at the top. auth.protect() answers per request type: a signed-out page request is redirected to the sign-in URL, a Server Action gets a 401, and any other non-document request, such as a Route Handler, gets a 404.

Two details catch people on the way.

  • Put the check in the page, not only the layout. A layout does not re-render on client-side navigation between its children, and Route Handlers and Server Functions never pass through it. The page is where the data is read, so that is where the check belongs.
  • Add a loading.tsx next to protected pages. With Cache Components, which new Next.js apps enable by default, a page that reads request data such as the session needs a Suspense boundary, and loading.tsx provides one. It changes the status code: a signed-out request then gets HTTP 200 with the redirect streamed in, instead of a 307. I hit the same streaming rule from the other side when notFound() returned 200 behind loading.tsx.

Can I keep the redirect in proxy.ts?

Yes, as a speed-up and not as the guard. Clerk's guide allows an early redirect for signed-out users after every resource has its own check:

// proxy.ts
import { clerkMiddleware } from '@clerk/nextjs/server'

export default clerkMiddleware(async (auth, req) => {
  // Not an auth guarantee: it only saves signed-out users a round trip.
  const { pathname } = req.nextUrl
  if (pathname === '/profile' || pathname.startsWith('/profile/')) {
    const { isAuthenticated, redirectToSignIn } = await auth()
    if (!isAuthenticated) return redirectToSignIn()
  }
})
Enter fullscreen mode Exit fullscreen mode

To check that the early redirect is not doing the real work, delete it and test signed out. Every protected page should still redirect, and every protected Route Handler should still refuse. If deleting a line in proxy.ts exposes data, that line was the guard and belongs in the resource.

Clerk also ships @clerk/eslint-plugin with a require-auth-protection rule. It flags App Router pages, Route Handlers and Server Functions under folders you mark protected that never call an auth check. That restores what deny by default gave the 2023 demo: a new page that forgets its check fails lint instead of shipping public.

The migration in four steps

  1. Upgrade @clerk/nextjs and replace authMiddleware from @clerk/nextjs with clerkMiddleware from @clerk/nextjs/server.
  2. On Next.js 16, rename middleware.ts to proxy.ts and replace the matcher with Clerk's current one.
  3. Add await auth.protect() to every page, Route Handler and Server Function that reads user data, with a loading.tsx beside protected pages.
  4. Remove the path checks from proxy.ts, test the app signed out, and turn on the lint rule so new routes cannot skip the check.

The demo's source and live dashboard still show the 2023 wiring, which is the starting point for step one.

Top comments (0)