Clerk is a complete authentication and user management solution — <ClerkProvider> wraps the app with auth context. <SignIn> and <SignUp> are drop-in components. useUser() returns { user, isLoaded } in Client Components. useAuth() returns { userId, sessionId, getToken }. clerkMiddleware() in middleware.ts protects routes — auth().protect() throws 404 for unauthenticated. currentUser() gets the user in Server Components. auth() in Server Actions returns the session. <UserButton> shows avatar with sign-out. <OrganizationSwitcher> enables multi-tenant team switching. Clerk webhooks sync users to your database on user.created/updated/deleted events. user.publicMetadata stores custom role and plan data. JWT templates add custom claims to tokens. Claude Code generates Clerk middleware, protected layouts, webhook handlers, and role-based access patterns for Next.js App Router.
CLAUDE.md for Clerk
## Clerk Stack
- Version: @clerk/nextjs >= 5.0
- Provider: ClerkProvider wraps layout.tsx — no config needed if CLERK_* env vars set
- Middleware: clerkMiddleware() in middleware.ts — createRouteMatcher for public routes
- Server Component: const user = await currentUser() — null if unauthenticated
- Server Action: const { userId } = auth(); if (!userId) throw new Error("Unauthorized")
- Client: const { user, isLoaded } = useUser() — const { userId, getToken } = useAuth()
- Protect: auth().protect() — redirects to sign-in, or { unauthorizedUrl: "/403" }
- Metadata: await clerkClient().users.updateUser(userId, { publicMetadata: { role: "admin" } })
- Webhook: svix verify signature → handle user.created/updated/deleted events
App Setup
// app/layout.tsx — ClerkProvider wraps everything
import { ClerkProvider } from "@clerk/nextjs"
import { dark } from "@clerk/themes"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider
appearance={{
baseTheme: undefined, // or dark for dark mode
variables: {
colorPrimary: "#3b82f6",
colorBackground: "#ffffff",
borderRadius: "0.5rem",
fontFamily: "Inter, sans-serif",
},
elements: {
formButtonPrimary: "bg-primary hover:bg-primary/90 text-primary-foreground",
card: "shadow-sm border",
},
}}
>
<html lang="en">
<body>{children}</body>
</html>
</ClerkProvider>
)
}
// middleware.ts — route protection
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"
// Define public routes that don't require authentication
const isPublicRoute = createRouteMatcher([
"/",
"/sign-in(.*)",
"/sign-up(.*)",
"/api/webhooks(.*)",
"/blog(.*)",
"/pricing",
])
export default clerkMiddleware(async (auth, request) => {
// Protect all non-public routes
if (!isPublicRoute(request)) {
await auth.protect()
}
})
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico).*)",
"/(api|trpc)(.*)",
],
}
Auth Pages
// app/sign-in/[[...sign-in]]/page.tsx — catch-all sign-in page
import { SignIn } from "@clerk/nextjs"
export default function SignInPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-muted/50">
<SignIn
appearance={{
elements: {
rootBox: "mx-auto",
card: "shadow-lg",
},
}}
redirectUrl="/dashboard"
/>
</div>
)
}
// app/sign-up/[[...sign-up]]/page.tsx
import { SignUp } from "@clerk/nextjs"
export default function SignUpPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-muted/50">
<SignUp redirectUrl="/onboarding" />
</div>
)
}
Protected Layouts and Server Components
// app/dashboard/layout.tsx — protected layout
import { auth, currentUser } from "@clerk/nextjs/server"
import { redirect } from "next/navigation"
import { UserButton, OrganizationSwitcher } from "@clerk/nextjs"
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const { userId } = await auth()
if (!userId) redirect("/sign-in")
const user = await currentUser()
return (
<div className="min-h-screen">
<header className="border-b">
<div className="container mx-auto flex items-center justify-between h-14 px-4">
<div className="flex items-center gap-4">
<span className="font-semibold">Dashboard</span>
<OrganizationSwitcher
appearance={{
elements: {
organizationSwitcherTrigger: "border rounded px-2 py-1",
},
}}
/>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
{user?.firstName} {user?.lastName}
</span>
<UserButton
afterSignOutUrl="/"
appearance={{
elements: { avatarBox: "size-8" },
}}
/>
</div>
</div>
</header>
<main className="container mx-auto py-6 px-4">{children}</main>
</div>
)
}
// app/dashboard/page.tsx — Server Component with user data
import { currentUser, auth } from "@clerk/nextjs/server"
import { db } from "@/lib/db"
export default async function DashboardPage() {
const clerkUser = await currentUser()
if (!clerkUser) return null
// Fetch from your own database using Clerk userId as foreign key
const userRecord = await db.user.findUnique({
where: { clerkId: clerkUser.id },
include: { subscription: true },
})
const role = clerkUser.publicMetadata.role as string | undefined
return (
<div>
<h1 className="text-2xl font-bold">Welcome back, {clerkUser.firstName}</h1>
<p className="text-muted-foreground">{clerkUser.emailAddresses[0].emailAddress}</p>
{role === "admin" && (
<div className="mt-4 p-3 bg-yellow-50 rounded-lg text-sm">
Admin access enabled
</div>
)}
</div>
)
}
Server Actions with Auth
// app/actions/post-actions.ts — protected Server Actions
"use server"
import { auth } from "@clerk/nextjs/server"
import { db } from "@/lib/db"
import { revalidatePath } from "next/cache"
import { z } from "zod"
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
published: z.boolean().optional().default(false),
})
export async function createPost(formData: FormData) {
const { userId } = await auth()
if (!userId) throw new Error("Unauthorized")
const input = CreatePostSchema.parse({
title: formData.get("title"),
content: formData.get("content"),
published: formData.get("published") === "on",
})
const post = await db.post.create({
data: { ...input, authorId: userId },
})
revalidatePath("/dashboard/posts")
return { id: post.id }
}
export async function deletePost(postId: string) {
const { userId } = await auth()
if (!userId) throw new Error("Unauthorized")
// Verify ownership
const post = await db.post.findFirst({
where: { id: postId, authorId: userId },
})
if (!post) throw new Error("Not found or forbidden")
await db.post.delete({ where: { id: postId } })
revalidatePath("/dashboard/posts")
}
// Admin-only action
export async function promoteToAdmin(targetUserId: string) {
const { userId } = await auth()
if (!userId) throw new Error("Unauthorized")
// Check caller is admin via publicMetadata
const { clerkClient } = await import("@clerk/nextjs/server")
const callerUser = await clerkClient().users.getUser(userId)
if (callerUser.publicMetadata.role !== "admin") {
throw new Error("Forbidden: admin access required")
}
// Set role on target user
await clerkClient().users.updateUser(targetUserId, {
publicMetadata: { role: "admin" },
})
}
Webhook Database Sync
// app/api/webhooks/clerk/route.ts — sync Clerk users to database
import { headers } from "next/headers"
import { Webhook } from "svix"
import { db } from "@/lib/db"
import type { WebhookEvent } from "@clerk/nextjs/server"
export async function POST(request: Request) {
const body = await request.text()
const headerPayload = await headers()
const svix_id = headerPayload.get("svix-id")
const svix_timestamp = headerPayload.get("svix-timestamp")
const svix_signature = headerPayload.get("svix-signature")
if (!svix_id || !svix_timestamp || !svix_signature) {
return new Response("Missing svix headers", { status: 400 })
}
const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!)
let event: WebhookEvent
try {
event = wh.verify(body, {
"svix-id": svix_id,
"svix-timestamp": svix_timestamp,
"svix-signature": svix_signature,
}) as WebhookEvent
} catch {
return new Response("Invalid signature", { status: 400 })
}
switch (event.type) {
case "user.created": {
const { id, email_addresses, first_name, last_name, image_url } = event.data
await db.user.create({
data: {
clerkId: id,
email: email_addresses[0].email_address,
name: [first_name, last_name].filter(Boolean).join(" ") || "Anonymous",
avatarUrl: image_url,
},
})
break
}
case "user.updated": {
const { id, email_addresses, first_name, last_name, image_url } = event.data
await db.user.update({
where: { clerkId: id },
data: {
email: email_addresses[0].email_address,
name: [first_name, last_name].filter(Boolean).join(" ") || "Anonymous",
avatarUrl: image_url,
},
})
break
}
case "user.deleted": {
if (event.data.id) {
await db.user.delete({ where: { clerkId: event.data.id } })
}
break
}
}
return new Response("OK", { status: 200 })
}
For the Better Auth alternative when a self-hosted, open-source TypeScript authentication library with Prisma/Drizzle adapters, email/password, OAuth, organizations, and two-factor is preferred — Better Auth runs entirely in your own infrastructure with no per-seat pricing, see the Better Auth guide. For the Auth.js (NextAuth) alternative when a battle-tested, community-driven authentication library with 50+ OAuth providers and official framework adapters is preferred — Auth.js v5 has a newer server-first API with auth() for both Server Components and middleware, see the NextAuth guide. The Claude Skills 360 bundle includes Clerk skill sets covering middleware, webhooks, and organization management. Start with the free tier to try auth integration generation.