Auth
Next.js boilerplate with Clerk
Hosted auth with prebuilt sign-in UI in your design's colours and a user sync webhook.
Hosted authentication: Clerk's sign-in, sign-up and account settings, restyled with your design's tokens in light and dark, with Google, GitHub and Microsoft switched on from the Clerk dashboard. With a database selected, a Svix-signed webhook mirrors users into your own tables.
What Clerk adds to the agent layer: 3 rules · 2 skills · 7 solution docs
Maintained by @raviMITNext.js on Vercel
From the manifest
Should you pick Clerk?
Pick it if
Teams who want finished auth UI and Clerk's hosted user dashboard on day one, with organisations there when they need them. The trade: the user record lives in Clerk's database. With a database selected, a webhook keeps a copy in yours.
Watch out for
- Prebuilt <SignIn /> and <SignUp /> components cover email, OAuth and account management out of the box. MFA needs the Pro plan.
- Users live in Clerk, not in your Postgres. Anything that joins users to your tables needs the webhook mirror this battery ships with a database. The mirror is eventually consistent: a signup can reach your app a beat before the webhook lands.
Show 4 moreShow fewer
- Roles live in publicMetadata by default. Convenient, but it is vendor state. Moving to a local roles table later is a migration, not a config change.
- Every server render that needs auth runs behind clerkMiddleware. Forget the proxy matcher and auth() throws at runtime instead of failing the build.
- Pricing counts monthly retained users: people who come back 24+ hours after signing up. A large free consumer product costs more here than self-hosted auth.
- Lock-in is real but bounded. Clerk exports users, and with a database selected this battery already keeps a copy in your own tables, keyed on your own id.
What it costs
Free Hobby plan up to 50,000 monthly retained users per app. Pro is $25/month, or $20/month billed yearly, then $0.02 per extra user. MFA needs Pro. Pro includes one enterprise SSO connection.
Prices change. Check with Clerk before you commit.
registry/tested.yaml
Tested with Clerk
Each pair was installed, typechecked, linted, built and booted together.
- Admin panel
- Admin panel
- Error tracking
- Sentry
- Customer support
- Crisp
What it adds
What Clerk adds to the repo
Read straight from the clerk manifest, so it is exactly what lands in your repo.
Environment variables
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYRequiredPublic, reaches the browser
Publishable key for the Clerk instance. Safe to ship to the browser.
- Where to get it
- https://dashboard.clerk.com/last-active?path=api-keys
- Placeholder
- pk_test_replace_me
CLERK_SECRET_KEYRequired
Backend API key. Server-only. Never import it into a client component.
- Where to get it
- https://dashboard.clerk.com/last-active?path=api-keys
- Placeholder
- sk_test_replace_me
NEXT_PUBLIC_CLERK_SIGN_IN_URLOptionalPublic, reaches the browser
Path of the sign-in page. Defaults to /sign-in.
- Placeholder
- /sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URLOptionalPublic, reaches the browser
Path of the sign-up page. Defaults to /sign-up.
- Placeholder
- /sign-up
Dependencies
- @clerk/nextjs^7.9.7
- server-only^0.0.1
Files it writes
29 files, at these exact paths.
src/17 files
app/3 files
(auth)/3 files
sign-in/1 file
[[...sign-in]]/1 file
- page.tsx
sign-up/1 file
[[...sign-up]]/1 file
- page.tsx
- layout.tsx
components/5 files
auth/5 files
- account-settings.tsx
- clerk-root.tsx
- clerk-user-profile.tsx
- header-auth-actions.tsx
- sign-out-button.tsx
lib/9 files
auth/9 files
- appearance.ts
- clerk-env.d.ts
- origin.ts
- proxy.ts
- publishable-key.ts
- redirect.test.ts
- redirect.ts
- roles.ts
- session.ts
tests/2 files
e2e/2 files
- auth.spec.ts
- auth.ts
variants/10 files
db-sync/5 files
scripts/1 file
- clerk-webhook.ts
src/4 files
app/1 file
api/1 file
webhooks/1 file
clerk/1 file
- route.ts
lib/3 files
auth/3 files
- user-sync.test.ts
- user-sync.ts
- webhook-idempotency.ts
orm-drizzle/3 files
slots/1 file
- db-schema.ts
src/2 files
db/1 file
- clerk-schema.ts
lib/1 file
auth/1 file
- user-store.ts
orm-prisma/2 files
slots/1 file
- prisma-models.prisma
src/1 file
lib/1 file
auth/1 file
- user-store.ts
Stack slots it fills
The stack declares these injection points; this battery supplies the fragment, so the provider tree, the env check and the schema stay one file each instead of many.
- @slot bare-route-groups
- @slot env-required
- @slot header-actions
- @slot legal-processors
- @slot middleware-matchers
- @slot providers
- @slot proxy-handlers
- @slot verify-checks
The differentiator
What Clerk teaches your agent
Other starter kits stop at the package. This is the part an agent reads: where it may work, what it must never do there, and the problems someone already solved.
Rules (3)
Loaded when the agent opens a matching file.
Authorise from the session, never from the client user object
Loads onsrc/app/**src/components/**src/lib/auth/**.claude/rules/authorise-on-the-server.md
The client user object is display data
useUser(), useAuth(), useOrganization() and the <Show> component
(<Show when="signed-in">, <Show when={{ role: "admin" }}>; Clerk Core 3
replaced <SignedIn>, <SignedOut> and <Protect> with it) exist to render the
right UI. They read state the browser
holds, which the browser can edit. They decide what is shown. They never
decide what is allowed.
// no: this is a visual hint being used as a control
const { user } = useUser();
if (user?.publicMetadata.role === "admin") {
await fetch("/api/refunds", { method: "POST", body });
}
The route handler behind that call is what an attacker invokes, with curl, without ever loading your page. It authorises itself:
const user = await requireApiRole("admin"); // from @/lib/auth/session
Hiding the button remains worth doing. It is the second step, not the only one.
unsafeMetadata is never an input to a decision
Clerk gives you three metadata buckets:
| Bucket | Written by | Trust |
|---|---|---|
publicMetadata | your backend only | authorisation input |
privateMetadata | your backend only, never sent to the browser | authorisation input |
unsafeMetadata | the signed-in user, from the browser | never |
unsafeMetadata is named that way for a reason: it is a scratchpad for user
preferences. Reading a role, an entitlement, a plan or a feature flag from it is
a self-service privilege escalation.
Read the role through one helper
Every role check in this repo goes through src/lib/auth/session.ts, which
reads the metadata session claim first and falls back to the Backend API only
when it is absent. Do not re-implement it:
- No
auth().sessionClaims?.metadata?.role === "admin"inline in a page. - No
clerkClient().users.getUser(id)in a component to look up a role. - No role strings outside
src/lib/auth/roles.ts.
One helper means one place to fix when the claim shape changes, and one place a security review has to read.
There are two ways in, and the difference is a network call:
| Helper | Answers | Cost |
|---|---|---|
getSessionClaims() / requireApiClaims() | id, role, orgId, impersonatedBy | the token only |
getSessionUser() / requireUser() / requireRole() | the whole SessionUser, including email and name | one Backend API call, cached per request |
Clerk's token does not carry the email address, so a helper that promises one has to fetch it. Guards that only decide yes-or-no should take the cheap path; anything that renders or emails the person takes the other. Both resolve the role identically, so neither is more or less trustworthy than the other.
Never take identity from the request
The user id comes from the session, never from the body, the query string or a header:
// no
const { userId } = await request.json();
await deleteAccount(userId);
// yes
const { id } = await requireApiUser();
await deleteAccount(id);
When an action legitimately targets another user (an admin banning someone) the target id may come from the request, but only after the actor has been authorised for that action.
auth() needs the proxy
auth() throws if clerkMiddleware did not run for the request. That is why
clerkProxy from src/lib/auth/proxy.ts is a step in src/proxy.ts, and why
the matcher there must cover every route that reads a session. A route that
"randomly" throws an auth error is almost always a route the matcher does not
reach.
The proxy is not the authorisation boundary either. It redirects signed-out
visitors early; the page, handler or action still checks the role. A page
under src/app/(app)/ calls requireUser(path) itself: a layout does not
re-run on client navigation.
Before the Clerk keys are set, clerkIsConfigured() is false, the proxy skips
Clerk, and every helper answers "signed out". Keep that path working: never
call auth() or a Clerk hook without going through those helpers or
clerkProviderMounted().
Impersonated sessions change nothing on the account
An administrator can sign in as someone from the Clerk dashboard. The token's
act claim then names the administrator, and SessionUser.impersonatedBy
carries its sub (null otherwise, and null for an AI agent actor). While it is
set, the settings pages are read-only summaries. Any new action that changes a
password, an email, a sign-in method or a payment method checks
impersonatedBy on the server and refuses, and anything it does write names
both ids in the log.
Never render <UserProfile />, <UserButton /> or <OrganizationProfile />
for an actor session: Clerk does not document hiding their forms for one. And never tell an
operator Clerk itself makes the session read-only. Clerk documents no such
lock, and the browser can call Clerk's Frontend API directly, so the app's
lock stops mistakes, not an admin who goes around it (see
docs/solutions/clerk/clerk-impersonation-and-the-act-claim.md).
Server-only imports
CLERK_SECRET_KEY and everything that reads it stay on the server.
src/lib/auth/session.ts starts with import "server-only" so an accidental
import from a client component is a build error rather than a leaked key.
Clerk's components wear the design's tokens and only render inside the provider
Loads onsrc/components/auth/**src/lib/auth/appearance.tssrc/app/(auth)/**src/components/site/header.tsx.claude/rules/clerk-ui-follows-the-design.md
Style Clerk through src/lib/auth/appearance.ts, with CSS variables
Every colour Clerk draws comes from clerkAppearance (sign-in, sign-up) or
userProfileAppearance (settings). Each value is one of the design's CSS
variables, var(--primary), var(--card), var(--foreground) and so on, so
Clerk follows a design swap and flips with light and dark on its own.
- Never a hex,
rgb()or Tailwind palette class in an appearance. The colour lint fails on it, and it would freeze one mode's colour into both. - No
colorBorder. Clerk draws borders from it at about 10% opacity, so a hairline token vanishes. Left out, borders derive fromcolorNeutral. - Layout tweaks go in
elementsas style objects, notclassName. Clerk's styles are unlayered and beat a Tailwind utility on the same property. - Keep
theme: "simple". It is Clerk's plain base (no gradient on the primary button, no drop shadows), so the variables carry the whole look. - Options are
appearance.optionsin Clerk Core 3 (it waslayout).
The sign-in and sign-up pages wrap Clerk in the design's Card and set
elevation: "flush", so the frame, radius and shadow are the design's. Do not
give Clerk its own card back.
Clerk hooks throw outside <ClerkProvider>
Since Clerk Core 3, useAuth, useUser and useClerk throw when no provider
is mounted, and ClerkRoot mounts none until the keys are set. Any client
component that uses a hook and can render on a public page is placed only when
clerkProviderMounted() (from src/lib/auth/publishable-key.ts) is true, with
a hook-free fallback otherwise. The header does this:
{clerkProviderMounted() ? <HeaderAuthActions /> : <SignedOutActions />}
Call clerkProviderMounted() on the server, in the component that places the
client one. Pages inside src/app/(app)/ need no check: nobody reaches them
without a session, which needs the keys.
Use <Show when="signed-in"> for conditional UI. <SignedIn>, <SignedOut>
and <Protect> are gone in Core 3.
The auth modules the app shell imports keep their contract
SignOutButton(src/components/auth/sign-out-button.tsx) wraps Clerk's<SignOutButton redirectUrl="/">around a real<button>, spreads every prop onto it and calls the caller'sonClickfirst, so it works as<DropdownMenuItem asChild>'s child. Keep all three.ProfileSettingsandSecuritySettings(account-settings.tsx) render Clerk's<UserProfile routing="hash">opened on itsaccountorsecuritypage, with Clerk's sidebar hidden (the settings tabs are the navigation). A new settings tab is a new page undersrc/app/(app)/settings/plus asettings-naventry, not a Clerk sidebar item.- While
user.impersonatedByis set, both render a read-only summary instead of Clerk's forms.
Redirects
Sign-in and sign-up land on AFTER_SIGN_IN (/dashboard) through
fallbackRedirectUrl, and on ?redirect_url= when a protected page sent the
visitor. Never forceRedirectUrl: it ignores redirect_url, and the person
loses the page they were trying to reach. Server code that reads
redirect_url passes it through safeRedirectPath from
src/lib/auth/redirect.ts. It checks the path after normalising, because
/..//evil.example only becomes //evil.example (another host) then.
The Clerk webhook verifies its signature before anything else
Loads onsrc/app/api/webhooks/clerk/**src/lib/auth/user-sync.tssrc/lib/auth/user-store.tssrc/lib/auth/webhook-idempotency.ts.claude/rules/verify-the-webhook-first.md
The endpoint is public, so the signature is the authentication
/api/webhooks/clerk is reachable by anyone on the internet. Clerk arrives with
no session and proves who it is with a Svix signature over the exact request
body. Until that signature is verified, the payload is a string a stranger sent
you.
Nothing above the verification call may:
- parse the body into an object and read a field from it,
- query or write the database,
- log the payload,
- return a response that differs by payload content.
The route is written in that order on purpose. Keep it.
Read the raw body, once
const payload = await request.text();
Not request.json(). The signature covers the exact bytes Clerk sent;
deserialising and re-serialising reorders keys and changes whitespace, and every
delivery then fails verification with a message that tells you nothing about
why. A request body can only be read once, so text-then-verify-then-parse is the
only order that works.
Failure responses
- Missing
svix-id,svix-timestamporsvix-signature→ 400. - Signature verification fails → 400, with a generic message. Never echo the verification error: an attacker probing the endpoint learns exactly how far their forgery got.
- The signing secret is not configured → 500. A missing secret must never be treated as "skip verification": that turns a deploy mistake into an open write endpoint.
- Handler threw while writing → 500, so Svix retries.
- Verified and handled, including events you ignore → 200.
Returning 200 for a failed write is the one that hurts: Svix stops retrying and the user is missing from your database forever.
Idempotency is the handler's job, not the ledger's
webhook-idempotency.ts deduplicates on svix-id in two layers: the
clerk_webhook_deliveries table, which is the one that holds across instances,
and an in-process Set in front of it that only saves a round trip on a warm
instance. Neither is the correctness story.
The writes themselves must be safe to repeat: upsert on the Clerk user id, and
ignore a payload whose updatedAt is older than the row it would overwrite,
that is what upsertUser returning "stale" means. Events can and do arrive out
of order.
Mark a delivery handled only after the handler succeeded. Marking on arrival swallows the retry of a delivery that failed halfway through: the one retry you actually need. Events the route ignores write no ledger row at all, because nothing happened that a retry could repeat.
Keep the mirror small
ClerkUserRecord holds the id, email, name, image and role. Every extra
mirrored field is another field that can drift from Clerk's copy. Anything you
can fetch on demand does not belong in your table.
The local row's primary key is your own uuid; the Clerk user id is a unique column beside it. Making the vendor's id your primary key spreads it through every foreign key in the schema and makes leaving expensive.
src/lib/auth/user-store.ts is the only file under src/lib/auth allowed to
name a table or import an ORM. Everything else speaks ClerkSyncStore, which is
what lets the same route ship to a Drizzle repo and a Prisma one.
Do not put business logic in this route
Send the welcome email, provision the workspace, add the trial: from a queue or a separate call, not inline. Slow work here means Svix times out and retries what already half-happened, and a failure in an unrelated feature makes the user sync fail too.
Skills (2)
Invoked by name.
- /add-clerk-role
Add a role to the Clerk-backed role vocabulary, wire it into the session claim, gate a route with it, and assign it safely.
.claude/skills/add-clerk-role/SKILL.md
- /sync-clerk-user
Extend, backfill or re-verify the Clerk user mirror: the users table and ClerkSyncStore that this battery already ships.
.claude/skills/sync-clerk-user/SKILL.md
Solution docs (7)
Written before you hit the problem. Each one ships in the repo at docs/solutions/ and is published here as a cookbook page.
- Clerk impersonation, the act claim, and what to lock while it is onWhen an admin signs in as a user from the Clerk dashboard, the session token carries an act claim. Read it on the server, show a banner, and refuse account changes until it ends.docs/solutions/clerk/clerk-impersonation-and-the-act-claim.md
- Protecting route handlers is not the same as protecting pagesA redirect is the right answer for a page and a terrible answer for fetch. Use 401 and 403 in route handlers, and never let the proxy be the only check.docs/solutions/clerk/protecting-handlers-vs-pages.md
- Clerk roles: publicMetadata or your own table?publicMetadata is free and instant but vendor state you cannot join on. A local roles table joins and audits but must be kept in sync. Pick per what you need to query.docs/solutions/clerk/roles-publicmetadata-vs-local-table.md
- Make Clerk's sign-in look native, in light and dark, with CSS variablesPoint Clerk's appearance variables at your design's CSS custom properties so the prebuilt components follow your theme and your dark mode, with no copied hex values.docs/solutions/clerk/styling-clerk-with-css-variables.md
- Testing a Clerk webhook locally without a tunnel round tripSign the payload yourself and POST it at localhost. You get replays, retry ids and bad-signature cases in one second instead of thirty.docs/solutions/clerk/testing-clerk-webhooks-locally.md
Show all 7Show fewer
- The proxy matcher that also matches your static assetsA matcher like "/(.*)" runs auth on every CSS file, image and font. The symptoms are an unstyled site, a redirect loop, or a surprising invocation bill.docs/solutions/clerk/the-matcher-that-ate-your-static-assets.md
- Clerk user-sync webhooks: duplicates, retries and out-of-order eventsSvix retries and can deliver twice, and updates can arrive before creates. Make the write idempotent on the user id and reject stale payloads by timestamp.docs/solutions/clerk/webhook-idempotency-and-ordering.md
How it fits
What Clerk needs, and what it goes well with
The resolver enforces this before it generates anything, and names every addition it makes.
Requires
Nothing. Clerk stands on its own.
Pairs well with
- A database battery. Suggested, never added for you.
Compared with the alternatives
Build a repo with Clerk
Free and MIT. The builder opens with Clerk picked. You download the zip right away, and we email you the link too.
Presets
Presets that already include Clerk
A tested selection with its own file tree and its own generated CLAUDE.md. Start from one instead of from blank.