diff --git a/.changeset/major-nuxt-drop-nuxt-3.md b/.changeset/major-nuxt-drop-nuxt-3.md
new file mode 100644
index 00000000000..98c036bec33
--- /dev/null
+++ b/.changeset/major-nuxt-drop-nuxt-3.md
@@ -0,0 +1,5 @@
+---
+'@clerk/nuxt': major
+---
+
+Drop support for Nuxt 3, which reaches end-of-life on July 31, 2026. `@clerk/nuxt` now requires Nuxt 4, and the minimum supported Node.js version is now `^20.19.0 || >=22.12.0` to match Nuxt 4's own requirement. If you are still on Nuxt 3, follow the [Nuxt upgrade guide](https://nuxt.com/docs/getting-started/upgrade) to upgrade your project before updating `@clerk/nuxt`.
diff --git a/.changeset/major-nuxt-remove-route-matcher.md b/.changeset/major-nuxt-remove-route-matcher.md
new file mode 100644
index 00000000000..abcf03453ca
--- /dev/null
+++ b/.changeset/major-nuxt-remove-route-matcher.md
@@ -0,0 +1,69 @@
+---
+'@clerk/nuxt': major
+---
+
+Remove `createRouteMatcher` from `@clerk/nuxt/server` and the auto-imported client-side `createRouteMatcher`. Middleware-based auth checks rely on path matching, which can diverge from how requests are actually routed and leave protected resources reachable. Move auth checks into the resources themselves.
+
+Protect API routes inside the event handler itself:
+
+```ts
+export default defineEventHandler(event => {
+ const { userId } = event.context.auth();
+
+ if (!userId) {
+ throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
+ }
+
+ return { userId };
+});
+```
+
+Protect pages with a named route middleware and opt pages into it with `definePageMeta()`. Child routes inherit the middleware applied to their parent, so a single declaration can protect a whole section:
+
+```ts
+// app/middleware/auth.ts
+export default defineNuxtRouteMiddleware(() => {
+ const { isSignedIn } = useAuth();
+
+ if (!isSignedIn.value) {
+ return navigateTo('/sign-in');
+ }
+});
+```
+
+```vue
+
+```
+
+If you want to hand this work to a coding agent, use this migration prompt:
+
+```md
+Migrate my Nuxt project away from Clerk's removed `createRouteMatcher` API.
+
+1. Find every matcher created with `createRouteMatcher`, along with the logic
+ that uses it (throwing 401 errors, calling `navigateTo('/sign-in')`, etc.).
+ Matchers can appear in Nitro server middleware (imported from
+ `@clerk/nuxt/server`) or in Nuxt route middleware (auto-imported).
+2. For every API route those matchers protected, move the auth check into the
+ event handler itself:
+ const { userId } = event.context.auth();
+ if (!userId) throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
+ Keep any role or permission checks (`event.context.auth().has(...)`) with
+ the resource as well.
+3. For every page those matchers protected, create a named route middleware in
+ `app/middleware/` that checks `useAuth()` and redirects with `navigateTo()`,
+ then opt pages into it with `definePageMeta({ middleware: 'auth' })`. Child
+ routes inherit the middleware applied to their parent.
+4. Remove the `createRouteMatcher` imports and calls. Keep `clerkMiddleware()`
+ itself. Middleware logic unrelated to auth protection (headers, locale
+ redirects, etc.) may stay, using plain `getRequestURL(event).pathname`
+ checks. Plain pathname checks do not normalize percent-encoding
+ (`/api/%61dmin` will not match a check for `/api/admin`), so never use
+ them for auth or security decisions. Those belong on the resource itself,
+ as in steps 2 and 3.
+5. Make sure every route previously covered by a matcher pattern (including
+ glob patterns like `/dashboard(.*)`) now has its own check, then verify the
+ project builds.
+```
diff --git a/integration/templates/nuxt-node/app/middleware/auth.global.js b/integration/templates/nuxt-node/app/middleware/auth.global.js
deleted file mode 100644
index 0e6f082773b..00000000000
--- a/integration/templates/nuxt-node/app/middleware/auth.global.js
+++ /dev/null
@@ -1,16 +0,0 @@
-export default defineNuxtRouteMiddleware(to => {
- const { userId } = useAuth();
-
- const isPublicPage = createRouteMatcher(['/sign-in(.*)', '/sign-up(.*)']);
- const isProtectedPage = createRouteMatcher(['/user-profile(.*)']);
-
- // Is authenticated and trying to access a public page
- if (userId.value && isPublicPage(to)) {
- return navigateTo('/user-profile');
- }
-
- // Is not authenticated and trying to access a protected page
- if (!userId.value && isProtectedPage(to)) {
- return navigateTo('/sign-in');
- }
-});
diff --git a/integration/templates/nuxt-node/app/middleware/auth.js b/integration/templates/nuxt-node/app/middleware/auth.js
new file mode 100644
index 00000000000..3ef9b853669
--- /dev/null
+++ b/integration/templates/nuxt-node/app/middleware/auth.js
@@ -0,0 +1,7 @@
+export default defineNuxtRouteMiddleware(() => {
+ const { isSignedIn } = useAuth();
+
+ if (!isSignedIn.value) {
+ return navigateTo('/sign-in');
+ }
+});
diff --git a/integration/templates/nuxt-node/app/pages/user-profile/[...slug].vue b/integration/templates/nuxt-node/app/pages/user-profile/[...slug].vue
index bc5be9d7d67..03dcb56f95b 100644
--- a/integration/templates/nuxt-node/app/pages/user-profile/[...slug].vue
+++ b/integration/templates/nuxt-node/app/pages/user-profile/[...slug].vue
@@ -1,4 +1,6 @@
diff --git a/integration/tests/nuxt/middleware.test.ts b/integration/tests/nuxt/middleware.test.ts
index 9b6e58a7a47..b12eb269a33 100644
--- a/integration/tests/nuxt/middleware.test.ts
+++ b/integration/tests/nuxt/middleware.test.ts
@@ -14,23 +14,21 @@ const nuxtConfigFile = () => `export default defineNuxtConfig({
}
});`;
-const clerkMiddlewareFile = () => `import { clerkMiddleware, createRouteMatcher } from '@clerk/nuxt/server';
+const clerkMiddlewareFile = () => `import { clerkMiddleware } from '@clerk/nuxt/server';
- const isProtectedRoute = createRouteMatcher(['/api/me', '/api/admin(.*)']);
+ export default clerkMiddleware();
+ `;
- export default clerkMiddleware((event) => {
+const adminApiRouteFile = () => `export default defineEventHandler((event) => {
const { userId } = event.context.auth();
- if (!userId && isProtectedRoute(event)) {
+ if (!userId) {
throw createError({
statusCode: 401,
statusMessage: 'You are not authorized to access this resource.'
})
}
- });
- `;
-const adminApiRouteFile = () => `export default defineEventHandler((event) => {
return { status: 'ok' };
});`;
@@ -44,7 +42,37 @@ const mePageFile = () => `