Skip to content

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 more
  • 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.

Database
NeonSupabase
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

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:

BucketWritten byTrust
publicMetadatayour backend onlyauthorisation input
privateMetadatayour backend only, never sent to the browserauthorisation input
unsafeMetadatathe signed-in user, from the browsernever

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:

HelperAnswersCost
getSessionClaims() / requireApiClaims()id, role, orgId, impersonatedBythe token only
getSessionUser() / requireUser() / requireRole()the whole SessionUser, including email and nameone 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 from colorNeutral.
  • Layout tweaks go in elements as style objects, not className. 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.options in Clerk Core 3 (it was layout).

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's onClick first, so it works as <DropdownMenuItem asChild>'s child. Keep all three.
  • ProfileSettings and SecuritySettings (account-settings.tsx) render Clerk's <UserProfile routing="hash"> opened on its account or security page, with Clerk's sidebar hidden (the settings tabs are the navigation). A new settings tab is a new page under src/app/(app)/settings/ plus a settings-nav entry, not a Clerk sidebar item.
  • While user.impersonatedBy is 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-timestamp or svix-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.

Show all 7

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.