Adding Auth to Next.js with Clerk: clerkMiddleware, Protected Routes, Webhooks and Orgs
Key takeaways
How Clerk splits authentication between hosted UI, a session cookie and your server code in Next.js: current setup for @clerk/nextjs v7 (Core 3), protecting pages and route handlers, syncing users into your database with verified webhooks, and checking organization roles.
What Clerk does, and what stays your job
Clerk is a hosted authentication and user-management service. It gives you sign-in and sign-up UI, OAuth providers, passwordless email codes, multi-factor authentication, session management and an admin dashboard. Your Next.js app receives a short-lived session token in a cookie and asks Clerk’s SDK who the current user is.
The appeal is that the hard, security-sensitive parts are no longer your code: password hashing, account-recovery flows, OAuth state and PKCE handling, rate-limiting sign-in attempts, rotating session tokens. The trade-off is a dependency on a third-party service for every sign-in, per-user pricing once you grow (check Clerk’s current pricing page rather than numbers in old blog posts), and user records that live in Clerk’s database rather than yours. That last point shapes the architecture. You will almost always need a copy of some user data in your own database, which is what the webhook section below is about.
Version note. This article targets @clerk/nextjs 7.x (“Clerk Core 3”, released March 2026) with the App Router. Many tutorials still show APIs that have since been removed: authMiddleware (replaced by clerkMiddleware), importing auth from @clerk/nextjs (it now lives in @clerk/nextjs/server and is async), and the <SignedIn> / <SignedOut> components (replaced by <Show>). If you copy older code, expect it to fail at build time or at render.
Installation and keys
npm install @clerk/nextjs
# .env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
The publishable key identifies your Clerk instance and is safe in the browser. The secret key can read and modify every user in your instance, so it must stay on the server. Next.js inlines every variable prefixed NEXT_PUBLIC_ into the client bundle, so never give the secret key that prefix, not even “temporarily” while debugging. Keep separate development and production Clerk instances. Development keys start with pk_test_/sk_test_, and production users do not exist in the development instance.
Middleware: attaching auth to requests
// middleware.ts (or proxy.ts on Next.js 16+), at the project root or in src/
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
const isProtectedRoute = createRouteMatcher(['/dashboard(.*)', '/api/private(.*)']);
export default clerkMiddleware(async (auth, req) => {
if (isProtectedRoute(req)) {
await auth.protect();
}
});
export const config = {
matcher: [
// Skip Next.js internals and static files
'/((?!_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)(.*)',
],
};
The most important thing to understand is the default. The old authMiddleware protected every route unless you listed it as public. clerkMiddleware does the opposite: it makes auth state available to every matched request and protects nothing until you call auth.protect() or check auth() yourself. Teams migrating from the old API can go from “everything private” to “everything public” without any error message, so after an upgrade, test a protected page in a private browser window.
auth.protect() behaves differently depending on the request. For a page request from a signed-out user, it redirects to the sign-in page. For a non-document request such as a fetch to an API route, it responds with 404 instead of a redirect, because a redirect to HTML is useless to a JSON client. A signed-in user who fails a role or permission check also gets a 404.
The matcher matters as well. The middleware must run on every route where you call auth(). If a page is excluded, auth() there throws an error that tells you it cannot find the middleware. On Next.js 16 and later, the file can be named proxy.ts (which runs on the Node.js runtime) instead of middleware.ts (Edge runtime); Clerk supports both.
Provider and prebuilt components
// app/layout.tsx
import { ClerkProvider } from '@clerk/nextjs';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body>{children}</body>
</html>
</ClerkProvider>
);
}
Sign-in and sign-up pages use optional catch-all routes, because Clerk’s components navigate through several steps (enter email, verify code, MFA) under the same base path:
// app/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from '@clerk/nextjs';
export default function SignInPage() {
return <SignIn />;
}
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
Without the catch-all folder name [[...sign-in]], the first step renders and the next one returns a 404. That is a common “Clerk is broken” report that is really a routing mistake.
For a header that changes with auth state, Core 3 uses <Show>:
// components/Header.tsx
import { Show, SignInButton, UserButton } from '@clerk/nextjs';
export default function Header() {
return (
<header>
<Show when="signed-in">
<UserButton />
</Show>
<Show when="signed-out">
<SignInButton mode="modal" />
</Show>
</header>
);
}
when also accepts authorization checks such as { role: ... } or { permission: ... }, and a fallback prop for the failing case. Treat that as a display decision only. Hiding a button does not protect the data behind it; the route handler or server action that does the work still has to check authorization.
Reading the user on the server
In Server Components, Route Handlers and Server Actions, use the async helpers from @clerk/nextjs/server:
// app/dashboard/page.tsx
import { auth, currentUser } from '@clerk/nextjs/server';
export default async function DashboardPage() {
const { userId, redirectToSignIn } = await auth();
if (!userId) return redirectToSignIn();
const user = await currentUser();
return <h1>Welcome, {user?.firstName ?? 'there'}!</h1>;
}
auth() only reads the verified session token, so it is cheap. currentUser() fetches the full user object from Clerk’s Backend API, which is a network call on every render, and it counts toward Clerk’s API rate limits. Use auth() for “who is this and are they allowed?” and call currentUser() only when you really need profile fields. Better still, read them from your own database once webhooks keep it in sync.
Prefer server-side checks over the client-side useUser() hook for protecting pages. A client component that redirects after isLoaded becomes true has already sent its JavaScript, and possibly flashed protected UI, before the redirect runs.
Protecting route handlers
// app/api/private/notes/route.ts
import { auth } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';
export async function GET() {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const notes = await db.note.findMany({ where: { ownerId: userId } });
return NextResponse.json(notes);
}
Returning your own 401 instead of calling auth.protect() gives API clients a clearer status code than the 404 described above. Whichever you choose, scope every query by userId (or organization id). Authentication tells you who is calling. It does not stop user A from requesting user B’s note by id, and that check is always yours to write.
Syncing users with webhooks
Because users live in Clerk, your database needs a way to learn about sign-ups, profile changes and deletions. Clerk sends these as webhooks through Svix. In the Clerk dashboard, add an endpoint such as https://your-app.com/api/webhooks/clerk, subscribe to the user.* events, and copy the signing secret into CLERK_WEBHOOK_SIGNING_SECRET.
// app/api/webhooks/clerk/route.ts
import { verifyWebhook } from '@clerk/nextjs/webhooks';
export async function POST(req: Request) {
let evt;
try {
evt = await verifyWebhook(req); // reads CLERK_WEBHOOK_SIGNING_SECRET
} catch (err) {
console.error('Webhook verification failed', err);
return new Response('Invalid signature', { status: 400 });
}
switch (evt.type) {
case 'user.created':
case 'user.updated':
await db.user.upsert({
where: { clerkId: evt.data.id },
create: { clerkId: evt.data.id, email: evt.data.email_addresses[0]?.email_address },
update: { email: evt.data.email_addresses[0]?.email_address },
});
break;
case 'user.deleted':
if (evt.data.id) await db.user.deleteMany({ where: { clerkId: evt.data.id } });
break;
}
return new Response('ok', { status: 200 });
}
The webhook route must stay public. If it matches your protected-route list, auth.protect() answers Clerk’s server with a 404 and the handler never runs. Verification uses the exact raw body and the svix-id, svix-timestamp and svix-signature headers. verifyWebhook handles that, but if you put a proxy or body-parsing layer in front of the route and it re-serializes the JSON, the signature no longer matches and every delivery fails with a 400.
A failure mode I have run into with this pattern is treating the webhook as a synchronous step of sign-up. The user finishes signing up, is redirected to /dashboard, and the dashboard queries the local users table. Sometimes the webhook has not arrived yet, so the row does not exist and the page errors. Webhooks are delivered asynchronously and can be retried or arrive out of order. Handlers should be idempotent (an upsert, as above), and pages should cope with a missing local row, either by creating it lazily from auth() data or by showing a short “setting up your account” state.
For local development, Clerk’s servers cannot reach localhost, so you need a tunnel (such as ngrok or Cloudflare Tunnel) pointing at your dev server. Register that URL as a separate endpoint in the development instance.
Organizations and roles
Organizations are Clerk’s multi-tenancy feature. A user can belong to several organizations, and one of them is active in the session. The prebuilt components cover switching and management:
import { OrganizationSwitcher, OrganizationProfile } from '@clerk/nextjs';
export default function OrgSettings() {
return (
<>
<OrganizationSwitcher />
<OrganizationProfile />
</>
);
}
On the server, check authorization with has() from the auth object instead of comparing role strings by hand:
// app/admin/page.tsx
import { auth } from '@clerk/nextjs/server';
export default async function AdminPage() {
const { orgId, has } = await auth();
if (!orgId || !has({ role: 'org:admin' })) {
return <p>Access denied</p>;
}
return <div>Admin panel</div>;
}
The default role keys are prefixed, org:admin and org:member. Checking orgRole === 'admin' never matches and silently locks everyone out, or, if written as !== in a deny check, locks out the admins too. Where you can, check fine-grained permissions (has({ permission: 'org:billing:manage' })) rather than roles. That way, adding a new role later does not require hunting down every role comparison in the codebase. Also remember the active organization is per session: every query that reads tenant data should filter by orgId, not just by userId.