Clerk handles identity so you don't have to build it. But "just add ClerkProvider" glosses over how the session token moves from browser to server, how middleware decides who gets in, and how Organizations model the multi-tenant structure your B2B app actually needs.
█████╗ ██╗ ██╗████████╗██╗ ██╗ ██╔══██╗██║ ██║╚══██╔══╝██║ ██║ ███████║██║ ██║ ██║ ███████║ ██╔══██║██║ ██║ ██║ ██╔══██║ ██║ ██║╚██████╔╝ ██║ ██║ ██║ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝
Clerk is an auth cloud. Your app doesn't store passwords, manage sessions, or issue JWTs from scratch. Clerk does all that in its own infrastructure, and you consume it through three integration points: a CDN-delivered JavaScript bundle that runs in the browser, a Next.js SDK that wraps server-side session reads, and a webhook receiver that keeps your database in sync.
Browser │ │ @clerk/nextjs client bundle (loaded from CDN) │ · ClerkProvider injects auth context into React tree │ · Session cookie (__session) set by Clerk's frontend API │ · Short-lived JWT in cookie, refreshed silently every ~60s │ ▼ Next.js Middleware (proxy.ts / middleware.ts) │ │ clerkMiddleware() intercepts every request │ · Validates __session cookie signature │ · Attaches auth context to request headers │ · Does NOT protect routes directly (deprecated pattern) │ · Forwards request to route handler or page │ ▼ App Router: Server Component / Route Handler / Server Action │ │ auth() → userId, orgId, orgRole from JWT (cheap: no network) │ currentUser() → full user profile (network call to Clerk API) │ │ Protect here, not in middleware. auth().protect() throws 401. │ ▼ Your Database (Neon / Supabase) │ │ clerk_user_id column links Clerk identity to app data │ Populated by webhook: user.created → upsert users table │ Never create DB rows from sign-up UI alone.
Clerk owns identity. Your database owns application data. The bridge is a single column: clerk_user_id. Every query that touches user-owned rows filters on this. The webhook keeps it populated. This split means you can swap your database without touching auth, and upgrade Clerk without migrating user rows.
Clerk issues short-lived JWTs. The default lifetime is 60 seconds. The browser refreshes them silently in the background. What lands on your server is a compact, signed token that encodes enough identity to answer most auth questions without a network round-trip.
The Auth object returned by auth() unwraps the token and exposes these fields:
user_2abc...). The foreign key for your DB rows.org:admin or org:member).The token is signed with Clerk's RSA private key. Your server verifies it with the public key. No database lookup required for this step.
A 60-second JWT means a revoked session can stay valid for up to 60 seconds after Clerk marks it invalid server-side. That is an intentional tradeoff. Shorter lifetime reduces the exposure window; the silent background refresh (via Clerk's frontend API) keeps the user experience seamless.
If you need instant revocation (e.g., for a security incident response flow), use Clerk's Backend API to revoke the session explicitly. Clerk's frontend will pick up the revocation on the next refresh cycle, at most 60 seconds later.
Clerk lets you add custom claims to the JWT via the Clerk Dashboard. The Supabase and Neon integrations use this: you configure a JWT template that includes the Supabase role claim, and Supabase RLS can then key off the Clerk-issued token directly.
Without a custom template, Supabase RLS can't read the Clerk user ID from the JWT. The alternative is to skip RLS and filter rows server-side with the clerk_user_id column. Both patterns work; the template approach is cleaner for row-level security at the database layer.
Every authenticated request to your Next.js app passes through Clerk's middleware. It validates the session cookie, populates the request with auth context, and decides how Clerk's frontend API requests are proxied. What it no longer does (as of 2026) is enforce route-level access rules.
For Next.js 16+, create proxy.ts at the project root. For Next.js 15 and older, the file is middleware.ts. The code is identical either way.
The older pattern of calling createRouteMatcher() inside clerkMiddleware() to protect routes is deprecated as of 2026. Clerk's current guidance: protect access as close to the resource as possible, meaning inside the Server Component, Route Handler, or Server Action that reads or mutates data. Middleware is a coarse gate; data-layer protection is what actually prevents unauthorized reads.
Clerk's frontend SDK makes requests to /__clerk/* for session refresh and sign-in callbacks. These go through your Next.js server before being proxied to Clerk's frontend API. If /__clerk/(.*) is missing from the matcher, Clerk's OAuth callbacks and token refreshes return 404. This is a silent failure: users get kicked out of sessions seemingly at random.
You can compose additional middleware inside the clerkMiddleware() callback. A common case is next-intl for i18n routing. Return the other middleware's response from the Clerk handler:
Two helpers cover 90% of server-side auth needs. They work in Server Components, Route Handlers, and Server Actions. The critical rule: protect inside the function that touches the data, not at the middleware edge.
| Helper | What it Returns | Network Call? | When to Use |
|---|---|---|---|
auth() |
userId, orgId, orgRole, orgPermissions, sessionId | No. Reads JWT from request headers. | Most cases. Checking if a user is signed in, reading their ID or org context. |
currentUser() |
Full Clerk User object: firstName, lastName, emailAddresses, imageUrl, metadata... | Yes. Fetches from Clerk API. | When you need profile fields. Avoid calling in every render; cache or fetch once per session. |
Call auth().protect() to enforce authentication. It throws a redirect to sign-in if the user is unauthenticated. The redirect URL is configured via NEXT_PUBLIC_CLERK_SIGN_IN_URL.
Same pattern. Call auth() at the top and return a 401 if the user isn't authenticated. For machine-to-machine requests (API keys, OAuth tokens), pass acceptsToken to tell Clerk what token types to validate.
clerkMiddleware() guards page and API routes. Server Actions bypass it. You must call await auth() or await auth.protect() at the top of every sensitive Server Action. Forgetting this leaves your mutations unguarded even if every page is protected.
Organizations are Clerk's multi-tenancy primitive. They map to what your product calls teams, workspaces, companies, or accounts. A user can belong to multiple organizations and switch between them at runtime. The currently selected one is the Active Organization.
Roles and Permissions are defined once at the application level in the Clerk Dashboard. Every organization within your app shares that role/permission schema. You can have org:admin, org:member, and any custom roles your product needs.
org:billing:read, org:members:delete).orgId, orgRole, and orgPermissions for the Active Organization.orgId = null in the token. Personal workspaces fall into this bucket.Clerk Application
│
├── Role definitions (application-level, shared by all orgs)
│ org:admin → [org:billing:read, org:members:manage, ...]
│ org:member → [org:billing:read, ...]
│
├── Organization A (e.g., "Acme Corp")
│ member: alice → role: org:admin
│ member: bob → role: org:member
│ Active for: alice's tab 1, bob's tab 1
│
├── Organization B (e.g., "Beta LLC")
│ member: alice → role: org:member
│ Active for: alice's tab 2
│
└── Personal account (orgId = null)
User: carol (no org membership)
Active for: carol's session
Each browser tab tracks its own Active Organization independently. If Alice has tab 1 showing Acme and tab 2 showing Beta, the session cookie reflects whichever tab last set the context. This is a singleton. The right approach for background fetches in multi-tenant apps: call getToken() on the client and pass the resulting token as an Authorization header. That ensures the server reads the correct org context for that specific request, regardless of the session cookie state.
After getting orgId from auth(), verify the user belongs to the organization that owns the resource. Never trust the URL alone. The orgId in the JWT is the canonical source of the user's active context.
Clerk bills organizations through Monthly Retained Organizations (MROs). An MRO is an organization with at least 2 members where at least 1 is a Monthly Retained User. Free plans get 100 MROs in production. If you're building a product where every user gets their own org (common for solo accounts), those single-member orgs don't count as MROs.
Clerk fires webhook events when user state changes. Your handler is the one place where you translate Clerk's user model into your database's user model. The pattern is simple: verify the signature, upsert on user.created/updated, delete or soft-delete on user.deleted.
The core events for a user sync handler:
If your middleware is blocking unauthenticated requests, the Clerk webhook POST arrives without a session and gets a 401 before your handler runs. Add /api/webhooks/(.*) to your public route list in clerkMiddleware. Or, since createRouteMatcher is deprecated, just don't call auth.protect() in the webhook handler itself.
Clerk sends a svix-signature header with every webhook. The verifyWebhook(req) helper validates it against CLERK_WEBHOOK_SECRET (from the Clerk Dashboard, under Webhooks). Never process the payload before calling verifyWebhook. The secret is per-endpoint, so each webhook endpoint in the Clerk Dashboard gets its own secret.
Register the webhook endpoint once per Clerk instance (test and live are separate). Deployments on different domains (Netlify preview URLs, Vercel preview deployments) need their own registrations if you want webhooks to fire during testing.
Clerk's client SDK ships prebuilt components for the common auth UI patterns. They handle OAuth callbacks, MFA sub-paths, error states, and branding. You can customize their appearance via the Clerk Dashboard without touching code.
Wrap your root layout in <ClerkProvider>. This injects the auth context that all hooks and components consume. It also loads the Clerk JavaScript bundle from Clerk's CDN.
Mount <SignIn /> on a catch-all route: app/sign-in/[[...sign-in]]/page.tsx. The double-bracket optional catch-all is required. A plain page.tsx breaks OAuth callback URLs and MFA sub-paths because Clerk appends paths like /sign-in/factor-one and /sign-in/sso-callback that need to match your route.
| Component | Route Pattern | Purpose |
|---|---|---|
<SignIn /> |
app/sign-in/[[...sign-in]]/page.tsx |
Full sign-in UI: email, password, OAuth, MFA, SSO callback handling |
<SignUp /> |
app/sign-up/[[...sign-up]]/page.tsx |
Full sign-up UI: email, OAuth, verification steps |
<UserButton /> |
Header / nav | Avatar dropdown: account settings, sign out. Includes org switcher if Organizations are enabled. |
<OrganizationSwitcher /> |
Header / nav | Switch between organizations, create new ones. Updates Active Organization in session. |
<OrganizationProfile /> |
Settings page | Manage org members, invitations, and profile. Can mount inline or as a modal. |
Client Components use hooks instead of the server-side helpers. The data comes from the ClerkProvider context, not from a server fetch.
user (Clerk User object) and isLoaded, isSignedIn. Equivalent of currentUser() on the client.userId, orgId, isLoaded, isSignedIn. Cheap; no network call.Clerk Core 3 introduced <Show> to conditionally render based on auth state. It replaces the older <SignedIn> and <SignedOut> wrapper components, which still work but are superseded.
Clerk handles identity. Your database handles everything else. The integration pattern is the same whether you're using Neon, Supabase, or any other Postgres. One foreign key column and one webhook handler is all it takes.
clerk_user_id TEXT column. This is the foreign key to Clerk's user record.users table. This is the only place you create DB users. Never create from sign-up UI directly.WHERE clerk_user_id = $1. Get userId from auth() server-side or useAuth() client-side.The minimal schema change for Clerk integration:
With Supabase, you have the option to enforce access at the database layer using RLS with Clerk's JWT template. This means Supabase can reject unauthorized reads before your application code even sees the row. With Neon, RLS isn't built into the serverless product the same way, so access enforcement lives in your application layer. Either approach works. The Supabase + RLS path adds defense-in-depth but requires more setup and careful JWT template configuration in the Clerk Dashboard.
These are the issues that show up in staging but bite hardest in production. Most of them are silent: no error, just wrong behavior.
If the user signs out in one tab and another tab makes a server request before the session is fully invalidated, auth() may return a non-null userId for up to 60 seconds. This is the 60-second JWT lifetime in practice. Design your data layer to tolerate this: treat the userId as a hint for which rows to return, not as proof of current authorization. For sensitive mutations, consider a secondary verification step or a shorter JWT lifetime via the Clerk Dashboard.
Clerk retries webhook delivery on failure, but if your endpoint is down or returns a non-2xx status, events can be delayed or missed. For critical provisioning flows (user.created → create org → assign initial data), add an idempotency key based on the event ID. Clerk includes a unique event ID in every payload. Use it as the primary key for a processed_events table and check before processing. This prevents duplicate DB writes on retries.
Clerk's test instance (pk_test_..., sk_test_...) and live instance (pk_live_..., sk_live_...) are completely separate. Users in test are not in live. Webhooks registered in test do not fire in live. The most common mistake: setting test keys in production environment variables. The error is not obvious, users just can't sign in. Verify key prefixes in your deployment config before every production launch.
CLERK_SECRET_KEY allows full API access to all users in your Clerk instance. It must never appear in browser bundles, client-side code, or public repositories. Store it in your platform's secret management (Vercel encrypted env vars, Railway secrets, etc.). The publishable key (NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY) is safe to expose; that's its purpose. If you suspect the secret key leaked, revoke it immediately from the Clerk Dashboard and rotate.
The session cookie is a singleton for the browser. If Alice has two tabs open, each in a different Organization, background fetches from either tab may carry the wrong org context in the cookie. For any background operation in a multi-tenant context, call getToken() on the client and pass the resulting token in an Authorization header. The server reads that token's orgId, not the cookie's. This is the correct pattern for things like background data polling, service workers, and fetch-based mutations.
Older Clerk docs (and many blog posts) show authMiddleware from @clerk/nextjs. It is removed. The replacement is clerkMiddleware from @clerk/nextjs/server. Any project still importing authMiddleware will get a runtime error after upgrading the SDK. When pulling in code from tutorials or Stack Overflow, check the import path and function name before using it.
Clerk's free tier is generous for solo projects. The pricing model shifts around two metrics: Monthly Active Users (MAUs) and Monthly Retained Organizations (MROs). Understanding the inflection points before you scale saves surprises.
| Tier | MAUs | MROs (prod) | Overage | Best For |
|---|---|---|---|---|
| Free | 10,000 | 100 | $0 (locked out of new signups) | Early products, side projects, MVPs |
| Pro | Included allowance + $0.02/MAU over | Included + $1/MRO over | Metered | Growing SaaS products, B2B apps |
| Enterprise | Negotiated | Negotiated | Contracted | Compliance requirements, custom SLAs |
For B2C products (individual users, no orgs), you'll hit the MAU limit first. 10,000 MAUs is meaningful scale but not huge. For B2B products with organizations, you can easily have 80 active companies with 500 total users and still be on the free tier, because MROs count organizations not users. The $1/MRO overage on Pro is predictable: at 500 orgs, that's $400/month in MRO costs. Model it against your ARPU before assuming it's cheap.
clerk_user_id column on every user-owned table. Webhooks populate it.clerkMiddleware() validates the session and populates request context. It does not protect routes. Protect access inside the Server Component, Route Handler, or Server Action that reads the data.auth() for the common case: checking authentication and reading userId, orgId, orgPermissions from the JWT. No network call. Use currentUser() only when you need full profile data.await auth() at the top of every sensitive Server Action or your mutations are unguarded.[[...sign-in]]) is required for <SignIn> and <SignUp>. A plain page breaks OAuth callbacks and MFA sub-paths./__clerk/(.*) matcher is required. Missing it causes silent session refresh failures: users appear to be logged out spontaneously.getToken() and pass the token in the Authorization header to get the correct org context.CLERK_SECRET_KEY is full-access to your entire user base. Treat it like a root password. Never bundle it client-side or expose it in logs.verifyWebhook(req) validates the Svix signature. Processing without verification means any HTTP client can send fake user events to your endpoint.Clerk Next.js SDK reference · clerkMiddleware() · Webhook sync guide · Organizations · Clerk + Neon · Clerk + Supabase JWT