Clerk - Complete authentication for modern applications
What is Clerk?
Clerk is a next-generation authentication platform that solves all problems related to user authentication in web applications. Unlike traditional solutions like Auth0 or Firebase Auth, Clerk offers:
- Ready-to-use UI components - professional sign-in and sign-up forms
- User management - a full administrative dashboard
- Organizations and teams - multi-tenant with roles and permissions
- Webhooks - automation on user events
- Edge-ready - works in serverless and edge environments
Clerk is particularly popular in the Next.js ecosystem, where integration takes literally just a few minutes.
Why Clerk?
The problem with authentication
Implementing authentication from scratch is a nightmare:
- Password hashing and security
- Sessions, tokens, refreshing
- Social login (Google, GitHub, Apple...)
- Password reset, email verification
- MFA/2FA
- Session management across multiple devices
- GDPR, compliance, audits
Clerk solves everything
// This is all you need for full authentication!
import { SignIn, SignUp, UserButton } from '@clerk/nextjs'
export default function Auth() {
return (
<>
<SignIn /> {/* Sign-in form */}
<SignUp /> {/* Sign-up form */}
<UserButton /> {/* Avatar with user menu */}
</>
)
}Installation and configuration
Next.js (App Router)
npm install @clerk/nextjsAPI keys
Create an account at clerk.com and get your keys:
# .env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
# Optional - redirect paths
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboardingMiddleware (route protection)
// middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
// Define public routes
const isPublicRoute = createRouteMatcher([
'/',
'/sign-in(.*)',
'/sign-up(.*)',
'/api/webhooks(.*)',
])
export default clerkMiddleware((auth, request) => {
// Protected routes require authentication
if (!isPublicRoute(request)) {
auth().protect()
}
})
export const config = {
matcher: [
// Skip static files and _next
'/((?!_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)(.*)',
],
}ClerkProvider
// app/layout.tsx
import { ClerkProvider } from '@clerk/nextjs'
import { enUS } from '@clerk/localizations'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<ClerkProvider
localization={enUS} // English localization
appearance={{
elements: {
// Appearance customization
formButtonPrimary: 'bg-blue-600 hover:bg-blue-700',
card: 'shadow-lg',
}
}}
>
<html lang="en">
<body>{children}</body>
</html>
</ClerkProvider>
)
}UI components
SignIn and SignUp
// app/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from '@clerk/nextjs'
export default function SignInPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<SignIn
appearance={{
elements: {
rootBox: 'mx-auto',
card: 'bg-white shadow-xl rounded-xl',
}
}}
routing="path"
path="/sign-in"
signUpUrl="/sign-up"
/>
</div>
)
}// app/sign-up/[[...sign-up]]/page.tsx
import { SignUp } from '@clerk/nextjs'
export default function SignUpPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<SignUp
routing="path"
path="/sign-up"
signInUrl="/sign-in"
/>
</div>
)
}UserButton and UserProfile
import { UserButton, UserProfile } from '@clerk/nextjs'
export function Header() {
return (
<header className="flex justify-between p-4">
<Logo />
<UserButton
afterSignOutUrl="/"
appearance={{
elements: {
avatarBox: 'w-10 h-10',
}
}}
>
{/* Custom menu items */}
<UserButton.MenuItems>
<UserButton.Link
label="Settings"
labelIcon={<SettingsIcon />}
href="/settings"
/>
<UserButton.Action label="Help" onClick={() => openHelp()} />
</UserButton.MenuItems>
</UserButton>
</header>
)
}
// Full profile page
export function ProfilePage() {
return (
<UserProfile
appearance={{
elements: {
card: 'shadow-none',
}
}}
/>
)
}SignedIn and SignedOut
import { SignedIn, SignedOut, SignInButton } from '@clerk/nextjs'
export function AuthStatus() {
return (
<>
<SignedIn>
{/* Visible only to signed-in users */}
<p>You are signed in!</p>
<UserButton />
</SignedIn>
<SignedOut>
{/* Visible only to signed-out users */}
<SignInButton mode="modal">
<button className="px-4 py-2 bg-blue-600 text-white rounded">
Sign in
</button>
</SignInButton>
</SignedOut>
</>
)
}Server-side authentication
Server Components
// app/dashboard/page.tsx
import { currentUser, auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const user = await currentUser()
if (!user) {
redirect('/sign-in')
}
return (
<div>
<h1>Welcome, {user.firstName}!</h1>
<p>Email: {user.emailAddresses[0].emailAddress}</p>
<p>ID: {user.id}</p>
{/* User metadata */}
<pre>{JSON.stringify(user.publicMetadata, null, 2)}</pre>
</div>
)
}auth() helper
import { auth } from '@clerk/nextjs/server'
export default async function ProtectedPage() {
const { userId, sessionClaims, orgId, orgRole } = auth()
if (!userId) {
return <div>Unauthorized</div>
}
// Check role in organization
if (orgRole !== 'admin') {
return <div>No administrator permissions</div>
}
return <AdminDashboard />
}Server Actions
'use server'
import { auth, currentUser } from '@clerk/nextjs/server'
import { revalidatePath } from 'next/cache'
export async function updateProfile(formData: FormData) {
const { userId } = auth()
if (!userId) {
throw new Error('Unauthorized')
}
const name = formData.get('name') as string
await db.users.update({
where: { clerkId: userId },
data: { name }
})
revalidatePath('/profile')
}
export async function createPost(formData: FormData) {
const user = await currentUser()
if (!user) {
throw new Error('You must be signed in')
}
const title = formData.get('title') as string
const content = formData.get('content') as string
await db.posts.create({
data: {
title,
content,
authorId: user.id,
authorName: user.firstName,
}
})
revalidatePath('/posts')
}API Routes
// app/api/users/route.ts
import { auth } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'
export async function GET() {
const { userId } = auth()
if (!userId) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
const user = await db.users.findUnique({
where: { clerkId: userId }
})
return NextResponse.json(user)
}
export async function POST(request: Request) {
const { userId } = auth()
if (!userId) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
const body = await request.json()
// Only admins can create users
const currentUserData = await db.users.findUnique({
where: { clerkId: userId }
})
if (currentUserData?.role !== 'admin') {
return NextResponse.json(
{ error: 'Forbidden' },
{ status: 403 }
)
}
const newUser = await db.users.create({ data: body })
return NextResponse.json(newUser, { status: 201 })
}Client-side hooks
useUser
'use client'
import { useUser } from '@clerk/nextjs'
export function ProfileCard() {
const { isLoaded, isSignedIn, user } = useUser()
if (!isLoaded) {
return <Skeleton />
}
if (!isSignedIn) {
return <SignInPrompt />
}
return (
<div className="p-6 bg-white rounded-lg shadow">
<img
src={user.imageUrl}
alt={user.fullName || 'Avatar'}
className="w-20 h-20 rounded-full"
/>
<h2 className="mt-4 text-xl font-bold">{user.fullName}</h2>
<p className="text-gray-500">
{user.primaryEmailAddress?.emailAddress}
</p>
{/* Metadata */}
<div className="mt-4">
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-sm">
{user.publicMetadata.plan || 'Free'}
</span>
</div>
</div>
)
}useAuth
'use client'
import { useAuth } from '@clerk/nextjs'
export function ProtectedAction() {
const { isLoaded, userId, sessionId, getToken } = useAuth()
async function callProtectedAPI() {
// Get JWT token
const token = await getToken()
const response = await fetch('/api/protected', {
headers: {
Authorization: `Bearer ${token}`
}
})
return response.json()
}
async function callExternalAPI() {
// Token for an external API (e.g. Supabase)
const token = await getToken({ template: 'supabase' })
// Use the token with the Supabase client
}
if (!isLoaded) return <Loading />
if (!userId) return <SignInRequired />
return (
<button onClick={callProtectedAPI}>
Call protected API
</button>
)
}useSession
'use client'
import { useSession } from '@clerk/nextjs'
export function SessionInfo() {
const { isLoaded, session } = useSession()
if (!isLoaded || !session) return null
return (
<div>
<p>Session ID: {session.id}</p>
<p>Created: {session.createdAt.toLocaleDateString()}</p>
<p>Last activity: {session.lastActiveAt.toLocaleDateString()}</p>
<button onClick={() => session.end()}>
Sign out from this device
</button>
</div>
)
}Organizations (multi-tenant)
Organization configuration
// app/layout.tsx - enable organizations
<ClerkProvider
organizationSyncOptions={{
enabled: true,
syncOnRedirect: true,
}}
>
{children}
</ClerkProvider>Organization components
import {
OrganizationSwitcher,
OrganizationProfile,
CreateOrganization,
OrganizationList,
} from '@clerk/nextjs'
export function OrgHeader() {
return (
<header className="flex items-center gap-4">
{/* Organization switcher */}
<OrganizationSwitcher
hidePersonal // Hide personal account
afterSelectOrganizationUrl="/org/:slug"
afterCreateOrganizationUrl="/org/:slug"
appearance={{
elements: {
organizationSwitcherTrigger: 'border rounded px-3 py-2',
}
}}
/>
</header>
)
}
// Organization management page
export function OrgSettingsPage() {
return (
<OrganizationProfile
appearance={{
elements: {
card: 'shadow-lg',
}
}}
/>
)
}
// User's organization list
export function MyOrganizations() {
return (
<OrganizationList
afterSelectOrganizationUrl="/org/:slug"
afterCreateOrganizationUrl="/org/:slug/settings"
/>
)
}useOrganization hook
'use client'
import { useOrganization, useOrganizationList } from '@clerk/nextjs'
export function TeamDashboard() {
const { organization, membership, isLoaded } = useOrganization()
if (!isLoaded) return <Loading />
if (!organization) return <NoOrgSelected />
const isAdmin = membership?.role === 'admin'
return (
<div>
<h1>{organization.name}</h1>
<img src={organization.imageUrl} alt={organization.name} />
{/* Only admin sees settings */}
{isAdmin && (
<button onClick={() => openSettings()}>
Team settings
</button>
)}
{/* Members list */}
<MembersList orgId={organization.id} />
</div>
)
}
export function OrgSelector() {
const { organizationList, isLoaded, setActive } = useOrganizationList()
if (!isLoaded) return <Loading />
return (
<ul>
{organizationList?.map(({ organization }) => (
<li key={organization.id}>
<button onClick={() => setActive({ organization: organization.id })}>
{organization.name}
</button>
</li>
))}
</ul>
)
}Roles and permissions
// middleware.ts - organization route protection
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
const isOrgRoute = createRouteMatcher(['/org/:slug(.*)'])
const isAdminRoute = createRouteMatcher(['/org/:slug/admin(.*)'])
export default clerkMiddleware((auth, request) => {
// Organization routes require membership
if (isOrgRoute(request)) {
auth().protect({
organizationId: true, // Requires active organization
})
}
// Admin routes require the admin role
if (isAdminRoute(request)) {
auth().protect({
role: 'org:admin',
})
}
})// Checking roles in a component
import { Protect } from '@clerk/nextjs'
export function AdminPanel() {
return (
<Protect
role="org:admin"
fallback={<p>Only administrators have access</p>}
>
<AdminDashboard />
</Protect>
)
}
// Or with a hook
export function RoleBasedUI() {
const { has } = useAuth()
const canManageMembers = has({ role: 'org:admin' })
const canViewReports = has({ permission: 'org:reports:read' })
return (
<div>
{canManageMembers && <MembersManager />}
{canViewReports && <ReportsViewer />}
</div>
)
}Webhooks
Webhook configuration
// app/api/webhooks/clerk/route.ts
import { Webhook } from 'svix'
import { headers } from 'next/headers'
import { WebhookEvent } from '@clerk/nextjs/server'
export async function POST(req: Request) {
const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET
if (!WEBHOOK_SECRET) {
throw new Error('CLERK_WEBHOOK_SECRET is required')
}
// Get headers
const headerPayload = 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 })
}
// Get body
const payload = await req.json()
const body = JSON.stringify(payload)
// Verify webhook
const wh = new Webhook(WEBHOOK_SECRET)
let evt: WebhookEvent
try {
evt = wh.verify(body, {
'svix-id': svix_id,
'svix-timestamp': svix_timestamp,
'svix-signature': svix_signature,
}) as WebhookEvent
} catch (err) {
console.error('Webhook verification failed:', err)
return new Response('Invalid signature', { status: 400 })
}
// Handle events
const eventType = evt.type
switch (eventType) {
case 'user.created':
await handleUserCreated(evt.data)
break
case 'user.updated':
await handleUserUpdated(evt.data)
break
case 'user.deleted':
await handleUserDeleted(evt.data)
break
case 'organization.created':
await handleOrgCreated(evt.data)
break
case 'organizationMembership.created':
await handleMemberAdded(evt.data)
break
default:
console.log(`Unhandled event type: ${eventType}`)
}
return new Response('OK', { status: 200 })
}
async function handleUserCreated(data: any) {
const { id, email_addresses, first_name, last_name, image_url } = data
await db.users.create({
data: {
clerkId: id,
email: email_addresses[0]?.email_address,
firstName: first_name,
lastName: last_name,
imageUrl: image_url,
}
})
// Send welcome email
await sendWelcomeEmail(email_addresses[0]?.email_address)
}
async function handleUserDeleted(data: any) {
const { id } = data
// Soft delete or anonymization
await db.users.update({
where: { clerkId: id },
data: {
deletedAt: new Date(),
email: null,
firstName: 'Deleted',
lastName: 'User',
}
})
}Database integration
User synchronization
// lib/sync-user.ts
import { currentUser } from '@clerk/nextjs/server'
import { db } from '@/lib/db'
export async function syncUser() {
const clerkUser = await currentUser()
if (!clerkUser) return null
// Find or create user in the database
let user = await db.users.findUnique({
where: { clerkId: clerkUser.id }
})
if (!user) {
user = await db.users.create({
data: {
clerkId: clerkUser.id,
email: clerkUser.emailAddresses[0]?.emailAddress,
firstName: clerkUser.firstName,
lastName: clerkUser.lastName,
imageUrl: clerkUser.imageUrl,
}
})
}
return user
}
// Usage in a Server Component
export default async function DashboardPage() {
const user = await syncUser()
if (!user) redirect('/sign-in')
const posts = await db.posts.findMany({
where: { authorId: user.id }
})
return <PostsList posts={posts} />
}Prisma Schema
// prisma/schema.prisma
model User {
id String @id @default(cuid())
clerkId String @unique
email String? @unique
firstName String?
lastName String?
imageUrl String?
role Role @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
posts Post[]
comments Comment[]
}
enum Role {
USER
ADMIN
MODERATOR
}
model Post {
id String @id @default(cuid())
title String
content String?
published Boolean @default(false)
authorId String
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
}Social login and SSO
Dashboard configuration
In the Clerk dashboard, enable providers:
- Google OAuth
- GitHub OAuth
- Apple Sign In
- Microsoft
- Discord
- Twitch
SAML SSO (Enterprise)
// For enterprise customers with their own IdP
<SignIn
appearance={{
elements: {
socialButtonsBlockButton: 'flex items-center gap-2',
}
}}
/>Custom OAuth
// app/api/oauth/custom/route.ts
import { auth } from '@clerk/nextjs/server'
export async function GET(request: Request) {
const { userId } = auth()
// Redirect to external OAuth
const authUrl = new URL('https://external-service.com/oauth/authorize')
authUrl.searchParams.set('client_id', process.env.EXTERNAL_CLIENT_ID!)
authUrl.searchParams.set('redirect_uri', `${process.env.NEXT_PUBLIC_URL}/api/oauth/callback`)
authUrl.searchParams.set('state', userId || '')
return Response.redirect(authUrl.toString())
}Appearance customization
Global customization
// app/layout.tsx
<ClerkProvider
appearance={{
// Base theme
baseTheme: dark,
// CSS variables
variables: {
colorPrimary: '#6366f1',
colorBackground: '#1f2937',
colorText: '#f9fafb',
colorInputBackground: '#374151',
borderRadius: '0.5rem',
fontFamily: 'Inter, sans-serif',
},
// Elements
elements: {
// Main card
card: 'bg-gray-800 border border-gray-700 shadow-xl',
// Buttons
formButtonPrimary:
'bg-indigo-600 hover:bg-indigo-700 text-white font-medium',
formButtonReset:
'text-gray-400 hover:text-white',
// Inputs
formFieldInput:
'bg-gray-700 border-gray-600 text-white placeholder-gray-400',
formFieldLabel:
'text-gray-300',
// Social buttons
socialButtonsBlockButton:
'bg-gray-700 border-gray-600 hover:bg-gray-600',
socialButtonsBlockButtonText:
'text-white font-medium',
// Divider
dividerLine: 'bg-gray-600',
dividerText: 'text-gray-400',
// Footer
footerActionLink:
'text-indigo-400 hover:text-indigo-300',
},
// Layouts
layout: {
socialButtonsPlacement: 'top',
socialButtonsVariant: 'blockButton',
termsPageUrl: '/terms',
privacyPageUrl: '/privacy',
},
}}
>Per-component customization
<SignIn
appearance={{
elements: {
rootBox: 'mx-auto w-full max-w-md',
card: 'rounded-2xl shadow-2xl',
headerTitle: 'text-2xl font-bold text-center',
headerSubtitle: 'text-gray-500 text-center',
formButtonPrimary: 'w-full py-3 text-lg',
}
}}
/>MFA/2FA
Enabling 2FA
In the Clerk dashboard, enable:
- SMS OTP
- Authenticator apps (TOTP)
- Backup codes
Enforcing 2FA
// app/settings/security/page.tsx
import { UserProfile } from '@clerk/nextjs'
export default function SecuritySettings() {
return (
<UserProfile>
<UserProfile.Page label="Security" url="security">
{/* The 2FA section is built-in */}
</UserProfile.Page>
</UserProfile>
)
}// Checking 2FA in middleware
export default clerkMiddleware((auth, request) => {
const { sessionClaims } = auth()
// Require 2FA for sensitive routes
if (request.url.includes('/admin')) {
if (!sessionClaims?.['2fa_enabled']) {
return Response.redirect(new URL('/setup-2fa', request.url))
}
}
})User metadata
Public vs Private Metadata
// Public metadata - visible on the client side
// Use for: subscription plan, role, UI preferences
// Private metadata - server-side only
// Use for: Stripe customer ID, internal flags
// Setting via API
import { clerkClient } from '@clerk/nextjs/server'
async function upgradeToPro(userId: string, stripeCustomerId: string) {
await clerkClient.users.updateUser(userId, {
publicMetadata: {
plan: 'pro',
planExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
privateMetadata: {
stripeCustomerId,
},
})
}
// Reading in a Server Component
const user = await currentUser()
const plan = user?.publicMetadata.plan as string || 'free'
// Reading in a Client Component
const { user } = useUser()
const plan = user?.publicMetadata.plan as string || 'free'Unsafe Metadata (user-editable)
// Users can edit unsafe metadata
'use client'
import { useUser } from '@clerk/nextjs'
export function PreferencesForm() {
const { user } = useUser()
async function updatePreferences(formData: FormData) {
await user?.update({
unsafeMetadata: {
theme: formData.get('theme'),
language: formData.get('language'),
notifications: formData.get('notifications') === 'on',
}
})
}
return (
<form action={updatePreferences}>
<select name="theme">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
{/* ... */}
</form>
)
}JWT Templates
Supabase integration
// In the Clerk Dashboard, create a JWT template called "supabase"
// with claims:
// {
// "sub": "{{user.id}}",
// "email": "{{user.primary_email_address}}",
// "role": "authenticated"
// }
// Usage
'use client'
import { useAuth } from '@clerk/nextjs'
import { createClient } from '@supabase/supabase-js'
export function useSupabase() {
const { getToken } = useAuth()
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
global: {
fetch: async (url, options = {}) => {
const clerkToken = await getToken({ template: 'supabase' })
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${clerkToken}`,
},
})
},
},
}
)
return supabase
}Clerk pricing (2025)
| Plan | Price | MAU | Features |
|---|---|---|---|
| Free | $0 | 10,000 | Basic auth, 5 social providers |
| Pro | $25/mo | +$0.02/MAU | Unlimited socials, custom domains |
| Enterprise | Custom | Unlimited | SAML SSO, SLA, support |
What is included in Free:
- 10,000 Monthly Active Users
- Email/password auth
- 5 social providers
- Organizations (up to 5)
- Webhooks
- Pre-built UI components
Pro adds:
- Unlimited social providers
- Custom domains
- SAML SSO
- Allowlists/blocklists
- Bot protection
- Advanced customization
Clerk vs alternatives
| Feature | Clerk | Auth0 | NextAuth | Firebase |
|---|---|---|---|---|
| Setup | 5 min | 30 min | 1h | 15 min |
| UI Components | Ready-made | None | None | Basic |
| Organizations | Yes | Yes (Pro) | No | No |
| Free tier | 10K MAU | 7K MAU | Unlimited | 50K/mo |
| Edge support | Yes | No | Yes | No |
| Webhooks | Yes | Yes | No | Yes |
| Self-host | No | No | Yes | No |
FAQ
How do I change the language to Polish?
import { plPL } from '@clerk/localizations'
<ClerkProvider localization={plPL}>How do I add custom fields during sign-up?
In the Clerk Dashboard, go to User & Authentication, then Email, Phone, Username, and add custom fields.
Does Clerk work with Pages Router?
Yes, Clerk supports both App Router and Pages Router.
How do I test locally?
Use test keys (pk_test_ and sk_test_). Clerk does not require tunnels for development.
How do I integrate with Stripe?
Use webhooks for synchronization and private metadata to store the Stripe customer ID.
Summary
Clerk is the best authentication solution for Next.js:
- Fast integration - working auth in minutes
- Ready-made UI - professional components
- Organizations - multi-tenant out of the box
- Type-safe - full TypeScript support
- Edge-ready - works in serverless and edge
- Generous free tier - 10K MAU for free
Use Clerk when you value your time and want to focus on building your product, not on authentication.