skills/clerk/SKILL.md
--- name: clerk description: Wire Clerk into a TanStack Start app on Cloudflare Workers using the dedicated @clerk/tanstack-react-start package. Install, env config, clerkMiddleware in src/start.ts, ClerkProvider in __root.tsx, drop-in UI components (SignIn, SignUp, UserButton, OrganizationSwitcher), server-side auth() helper for createServerFn, useUser / useAuth hooks for client routes, Drizzle shadow user table, webhook signature verification, organisations + multi-tenancy, plus a role taxonom
npx skillsauth add RonanCodes/ronan-skills skills/clerkInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Wire Clerk into a TanStack Start + Drizzle + D1 app on Cloudflare Workers. Hosted sign-in UI with drop-in React components, server-side session verification via the dedicated @clerk/tanstack-react-start package, organisations as first-class for B2B.
This is the canonical auth pick for the user's stack as of 2026-05-05. Optimised for small SaaS where speed-to-first-working-sign-in matters more than scale features. For B2B-at-scale (100K+ MAU expected, partner needs hosted Admin Portal, near-term SAML SSO), use /ro:workos. For own-the-table semantics (RLS / FKs / EU residency mandate / fully custom flows), use /ro:better-auth. Comparisons: llm-wiki-research/wiki/comparisons/auth-three-way-deep-dive.md.
Why the dedicated TanStack package: as of late 2025, Clerk ships @clerk/tanstack-react-start which integrates into TanStack Start's request middleware pipeline natively. It replaces the older @clerk/clerk-react + @clerk/backend two-package setup. The dedicated package handles env loading without requiring the VITE_ prefix dance, exposes a clean auth() helper for createServerFn handlers, and a clerkClient() for fetching full user data server-side.
/ro:clerk install # initial wiring (env + middleware + provider + sign-in routes + roles helper)
/ro:clerk install --social github,google # + GitHub + Google providers
/ro:clerk add-organizations # multi-tenant orgs + OrganizationSwitcher
/ro:clerk add-roles # role helper only (superadmin / staff / member + requireRole)
/ro:clerk add-webhook # /api/webhooks/clerk with svix signature verification
/ro:clerk open-dashboard # open the Clerk dashboard for this app
add-roles runs automatically inside install — it's exposed separately for retrofitting an app that already has Clerk wired but no role helper.
/ro:new-tanstack-app or /ro:migrate-to-tanstack)src/db/schema.ts, wrangler.toml with [[d1_databases]])pnpm add @clerk/tanstack-react-start
pnpm add svix # for webhook signature verification (only if using add-webhook)
The single @clerk/tanstack-react-start package ships drop-in components (client side), the clerkMiddleware() request integration, and auth() + clerkClient() helpers (server side). Runs in Workers, no Node-only APIs.
# .dev.vars (local dev) and .env (build-time fallback)
CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
CLERK_WEBHOOK_SECRET=whsec_... # only after add-webhook
Both keys come from the Clerk dashboard, API Keys page. Production keys (pk_live_, sk_live_) are separate from test keys. No VITE_ prefix needed with the new package; it handles client / server exposure internally.
Production secrets:
wrangler secret put CLERK_PUBLISHABLE_KEY
wrangler secret put CLERK_SECRET_KEY
wrangler secret put CLERK_WEBHOOK_SECRET # only after add-webhook
clerkMiddleware() to the start instance, src/start.tsimport { clerkMiddleware } from '@clerk/tanstack-react-start';
import { createStart } from '@tanstack/react-start';
export const startInstance = createStart(() => {
return {
requestMiddleware: [clerkMiddleware()],
};
});
clerkMiddleware() runs before every request, reads the session cookie or Authorization: Bearer header, verifies the JWT against Clerk's JWKS (cached automatically), and populates the auth context that auth() reads downstream. No round-trip to Clerk's servers per request.
<ClerkProvider>, src/routes/__root.tsximport { ClerkProvider } from '@clerk/tanstack-react-start';
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
import Header from '../components/Header';
import appCss from '../styles.css?url';
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'Your App' },
],
links: [{ rel: 'stylesheet', href: appCss }],
}),
shellComponent: RootDocument,
});
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<ClerkProvider>
<Header />
{children}
</ClerkProvider>
<TanStackRouterDevtools />
<Scripts />
</body>
</html>
);
}
Note: <ClerkProvider> does not take a publishableKey prop with this package. The middleware (Step 3) injects the publishable key into the SSR HTML, and the provider reads it from there. One less thing to wire.
src/routes/sign-in.tsximport { createFileRoute } from '@tanstack/react-router';
import { SignIn } from '@clerk/tanstack-react-start';
export const Route = createFileRoute('/sign-in')({
component: () => (
<div className="flex min-h-screen items-center justify-center">
<SignIn routing="path" path="/sign-in" signUpUrl="/sign-up" forceRedirectUrl="/dashboard" />
</div>
),
});
Mirror with src/routes/sign-up.tsx using <SignUp />. That is the entire sign-in UI. No callback handler to write, Clerk's hosted flow handles OAuth round-trips and email verification.
import { UserButton, SignedIn, SignedOut, SignInButton } from '@clerk/tanstack-react-start';
export function AppHeader() {
return (
<header className="flex items-center justify-between p-4">
<Logo />
<SignedIn>
<UserButton afterSignOutUrl="/" />
</SignedIn>
<SignedOut>
<SignInButton />
</SignedOut>
</header>
);
}
<UserButton /> renders the avatar with a menu including profile, account settings, sign-out, and (if orgs enabled) org switcher. <SignedIn> and <SignedOut> are control components that render their children only when the user is in the matching state. This is the headline DX argument for Clerk vs the alternatives.
auth()// src/routes/dashboard.tsx
import { createFileRoute, redirect } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import { auth } from '@clerk/tanstack-react-start/server';
const requireSessionFn = createServerFn({ method: 'GET' }).handler(async () => {
const { isAuthenticated, userId, orgId, orgRole } = await auth();
if (!isAuthenticated) {
throw redirect({ to: '/sign-in' });
}
return { userId, orgId, orgRole };
});
export const Route = createFileRoute('/dashboard')({
beforeLoad: () => requireSessionFn(),
loader: ({ context }) => context,
component: DashboardPage,
});
The session shape from auth() flows to the page via Route.useLoaderData() or Route.useRouteContext(). The middleware (Step 3) makes auth() cheap; it reads from already-verified context, no per-call JWT verification.
Three patterns depending on where you need the data, server vs client, and how much you need.
When the EARS criterion needs more than the userId (e.g. email, first name, image URL):
// src/routes/dashboard.tsx
import { createFileRoute, redirect } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import { auth, clerkClient } from '@clerk/tanstack-react-start/server';
const dashboardLoadFn = createServerFn().handler(async () => {
const { isAuthenticated, userId } = await auth();
if (!isAuthenticated) throw redirect({ to: '/sign-in' });
const user = await clerkClient().users.getUser(userId);
return {
userId,
firstName: user.firstName,
email: user.emailAddresses[0]?.emailAddress,
};
});
export const Route = createFileRoute('/dashboard')({
beforeLoad: () => dashboardLoadFn(),
loader: ({ context }) => context,
component: Dashboard,
});
function Dashboard() {
const { firstName } = Route.useLoaderData();
return <h1>Welcome, {firstName}</h1>;
}
For raw HTTP API endpoints (e.g. webhook receivers, REST endpoints called from outside the app):
// src/routes/api/example.ts
import { createFileRoute } from '@tanstack/react-router';
import { auth, clerkClient } from '@clerk/tanstack-react-start/server';
import { json } from '@tanstack/react-start';
export const ServerRoute = createFileRoute('/api/example')({
server: {
handlers: {
GET: async () => {
const { isAuthenticated, userId } = await auth();
if (!isAuthenticated) {
return new Response('Unauthorized', { status: 401 });
}
const user = await clerkClient().users.getUser(userId);
return json({ user });
},
},
},
});
useAuth() and useUser()Inside React components:
import { useAuth, useUser } from '@clerk/tanstack-react-start';
function ApiCallExample() {
const { isLoaded, isSignedIn, userId, getToken } = useAuth();
const callExternalApi = async () => {
const token = await getToken();
return fetch('https://api.example.com/data', {
headers: { Authorization: `Bearer ${token}` },
});
};
if (!isLoaded) return <div>Loading...</div>;
if (!isSignedIn) return <div>Sign in to view</div>;
return <button onClick={callExternalApi}>Fetch</button>;
}
function ProfileExample() {
const { isLoaded, isSignedIn, user } = useUser();
if (!isLoaded) return <div>Loading...</div>;
if (!isSignedIn) return <div>Sign in to view</div>;
return <div>Hello, {user.firstName}</div>;
}
useAuth() is light: token + auth state + IDs. Use when you only need to gate UI or get a session token. useUser() is heavier: full user object. Use sparingly; prefer fetching server-side via clerkClient() and passing through a route loader.
Per the authentication-hardening playbook (llm-wiki-security/wiki/playbooks/authentication-hardening.md), auth is the main attack surface, so enable a phishing-resistant factor by default. In the Clerk dashboard → "User & Authentication" → enable Passkeys (WebAuthn), and prefer them over SMS. Passkeys/FIDO2 meet NIST 800-63B AAL2+ and are the CISA gold standard; SMS and TOTP are phishable (a fake page relays the code in real time).
Clerk handles social providers in the dashboard, no app code change. Open the Application page, navigate to "User & Authentication" → "Social Connections", toggle GitHub or Google, paste the OAuth client ID + secret you got from each provider's developer console.
Callback URLs to register on the provider side are shown in the Clerk dashboard (per-provider, with copy buttons).
Clerk Organizations are first-class. Each Organization has Members, Roles, Invitations. Free up to 10K MAU.
Add the switcher anywhere in the shell:
import { OrganizationSwitcher, UserButton } from '@clerk/tanstack-react-start';
export function AppHeader() {
return (
<header className="flex items-center gap-4 p-4">
<Logo />
<OrganizationSwitcher
afterCreateOrganizationUrl="/dashboard"
afterSelectOrganizationUrl="/dashboard"
/>
<UserButton afterSignOutUrl="/" />
</header>
);
}
The active org propagates to the server-side auth() return as orgId and orgRole. Org-scoped data fetching:
const { orgId } = await auth();
if (!orgId) throw new Response('No active org', { status: 400 });
const rows = await db.select().from(merchants).where(eq(merchants.orgId, orgId));
Client-side hooks: useOrganization() returns the current org and membership, useOrganizationList() for switching, useUser() for the user object.
For per-org RBAC, use Clerk's built-in roles (org:admin, org:member, custom). auth().orgRole returns the active member's role, and Clerk's <Protect> component gates UI:
import { Protect } from '@clerk/tanstack-react-start';
<Protect role="org:admin">
<DangerZone />
</Protect>
requireRole() helperThree-tier role model, Clerk-native where possible. Used to gate admin routes like /styleguide and any future admin panel.
| Role | Source of truth | Who it's for | Why this shape |
|---|---|---|---|
| superadmin | Hardcoded SUPERADMIN_EMAILS constant in src/lib/auth/roles.ts | One or two product owners (e.g. [email protected]) | Belt-and-braces. Even if someone edits Clerk metadata or org roles directly in the dashboard, they cannot grant themselves superadmin without a code change + deploy. The hardcoded set is the recovery surface if Clerk itself is compromised |
| staff | Clerk org custom role org:staff | Employees / contractors who need admin-panel access but should not be able to alter billing, owners, or roles | Lives in Clerk so promotions/revocations are dashboard operations, no deploy needed |
| member | Clerk default org role org:member | Paying customers / regular signed-in users | Default. Anyone who completes sign-up lands here. No admin routes |
admin (mid-tier with delegation rights) is intentionally skipped for now — add it later when there's a real need to delegate staff promotion away from the superadmin. Most small SaaS never need this tier.
org:staff role in Clerk dashboardOne-time setup, per environment (test + production each need it):
https://dashboard.clerk.com → your application → Organizations → Rolesorg:staff, name Staff, description Internal team — admin panel access, no billing or role-management writesorg:sys_memberships:read so staff can see other org members. Skip org:sys_memberships:manage (that's the delegation power you'd grant a future org:admin)org:member exists out of the box and needs no configuration.
src/lib/auth/roles.tsimport { auth, clerkClient } from '@clerk/tanstack-react-start/server';
// Hardcoded recovery list. Edits to this list require a deploy — that's the point.
// Add the second person ONLY when there's a documented reason; superadmin is meant to be rare.
export const SUPERADMIN_EMAILS = ['[email protected]'] as const;
export type Role = 'superadmin' | 'staff' | 'member';
export async function getRole(): Promise<Role | null> {
const { isAuthenticated, userId, orgRole } = await auth();
if (!isAuthenticated || !userId) return null;
// Superadmin check: primary email against hardcoded set.
// Fetched server-side via clerkClient so a forged metadata edit can't bypass this.
const user = await clerkClient().users.getUser(userId);
const primaryEmail = user.emailAddresses.find(
(e) => e.id === user.primaryEmailAddressId,
)?.emailAddress;
if (primaryEmail && (SUPERADMIN_EMAILS as readonly string[]).includes(primaryEmail)) {
return 'superadmin';
}
// Clerk org custom role check.
if (orgRole === 'org:staff') return 'staff';
return 'member';
}
export async function requireRole(...allowed: Role[]): Promise<Role> {
const role = await getRole();
if (!role || !allowed.includes(role)) {
// 404, not 401/403: don't leak the existence of admin routes to unauthed visitors.
throw new Response('Not Found', { status: 404 });
}
return role;
}
The 404 (vs redirect to sign-in or a 403) is deliberate. From a signed-out browser, /styleguide should look identical to /anything-that-doesnt-exist. Anyone scraping for admin surfaces gets no signal.
// src/routes/styleguide.tsx
import { createFileRoute } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import { requireRole } from '@/lib/auth/roles';
const guardFn = createServerFn({ method: 'GET' }).handler(async () => {
return await requireRole('superadmin', 'staff');
});
export const Route = createFileRoute('/styleguide')({
beforeLoad: () => guardFn(),
component: StyleguidePage,
});
function StyleguidePage() {
// tokens + typography + shadcn component matrix render here
return <div>Style guide</div>;
}
The same pattern wraps any future admin route. The requireRole() call is the only line that changes per surface.
<Protect> alone<Protect role="org:staff"> is great for UI hiding (don't render a button) but it's client-side and trivially bypassed by a determined user. requireRole() runs server-side in beforeLoad, gates the route before any data leaks, and combines org role + hardcoded superadmin in one place. Use both: requireRole() to gate the route, <Protect> to hide UI surfaces within an already-gated page.
Clerk pushes events on user.created, user.updated, user.deleted, organization.created, etc. Sync to a shadow users row in your D1 so domain tables can FK to it.
// src/db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', {
id: text('id').primaryKey(), // clerk user_2abc...
email: text('email').notNull(),
firstName: text('first_name'),
lastName: text('last_name'),
imageUrl: text('image_url'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
deletedAt: integer('deleted_at', { mode: 'timestamp' }),
});
export const organizations = sqliteTable('organizations', {
id: text('id').primaryKey(), // clerk org_2xyz...
name: text('name').notNull(),
slug: text('slug'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
Foreign keys from your domain tables point at users.id (the Clerk user ID, not an internal one).
src/routes/api/webhooks/clerk.tsimport { createFileRoute } from '@tanstack/react-router';
import { Webhook } from 'svix';
import { db } from '@/db';
import { users, organizations } from '@/db/schema';
import { eq } from 'drizzle-orm';
export const ServerRoute = createFileRoute('/api/webhooks/clerk')({
server: {
handlers: {
POST: async ({ request, context }) => {
const env = context.cloudflare.env;
const svixId = request.headers.get('svix-id');
const svixTimestamp = request.headers.get('svix-timestamp');
const svixSignature = request.headers.get('svix-signature');
if (!svixId || !svixTimestamp || !svixSignature) {
return new Response('Missing svix headers', { status: 400 });
}
const body = await request.text();
const wh = new Webhook(env.CLERK_WEBHOOK_SECRET);
let event: { type: string; data: any };
try {
event = wh.verify(body, {
'svix-id': svixId,
'svix-timestamp': svixTimestamp,
'svix-signature': svixSignature,
}) as { type: string; data: any };
} catch {
return new Response('Bad signature', { status: 400 });
}
switch (event.type) {
case 'user.created':
case 'user.updated':
await db.insert(users).values({
id: event.data.id,
email: event.data.email_addresses[0]?.email_address ?? '',
firstName: event.data.first_name,
lastName: event.data.last_name,
imageUrl: event.data.image_url,
}).onConflictDoUpdate({
target: users.id,
set: {
email: event.data.email_addresses[0]?.email_address ?? '',
firstName: event.data.first_name,
lastName: event.data.last_name,
imageUrl: event.data.image_url,
updatedAt: new Date(),
},
});
break;
case 'user.deleted':
await db.update(users).set({ deletedAt: new Date() }).where(eq(users.id, event.data.id));
break;
case 'organization.created':
case 'organization.updated':
await db.insert(organizations).values({
id: event.data.id,
name: event.data.name,
slug: event.data.slug,
}).onConflictDoUpdate({
target: organizations.id,
set: { name: event.data.name, slug: event.data.slug },
});
break;
}
return new Response('ok');
},
},
},
});
In the Clerk dashboard, navigate to Webhooks, create an endpoint pointing at https://your-app.com/api/webhooks/clerk, copy the signing secret, push it as CLERK_WEBHOOK_SECRET (per-app, NOT global).
The shadow row is the tradeoff vs Better Auth. You do not own user attributes (email change happens in Clerk, syncs to you). Custom fields go in a separate table keyed by users.id.
Open the Clerk dashboard for this app:
open https://dashboard.clerk.com
Non-tech partners can be invited as Team Members under Organization Settings, with limited or admin scopes. They get a hosted UI to manage users, see signups, reset passwords, ban accounts, configure providers.
| Var | Where | Source |
|---|---|---|
| CLERK_PUBLISHABLE_KEY | .dev.vars + wrangler secret | Clerk dashboard, API Keys (separate test + live) |
| CLERK_SECRET_KEY | .dev.vars + wrangler secret | Clerk dashboard, API Keys |
| CLERK_WEBHOOK_SECRET | wrangler secret (only after add-webhook) | Clerk dashboard, Webhooks endpoint detail |
No VITE_ prefix; the new @clerk/tanstack-react-start package handles client-side exposure internally.
For small SaaS at sub-10K MAU, Clerk is effectively free forever. The killer scenario is greenfield products where shipping fast on hosted UI components is more valuable than the long-term cost curve.
/ro:workos (1M MAU free, same vendor shape)./ro:workos./ro:better-auth./ro:better-auth.The flip path is documented per-target in the auth comparison page.
If you flip away from Clerk to a self-hosted setup:
clerk_user_id to the new internal ID in your domain tables.Plan one engineer-week and a week of soft migration window. Same shape as the WorkOS migration plan.
CLERK_SECRET_KEY or CLERK_WEBHOOK_SECRET in a committed .env. Per-app secrets only. A leaked secret key lets anyone forge sessions for that one app.CLERK_PUBLISHABLE_KEY IS safe to ship to browsers (it is the bundle's identifier for which Clerk app to talk to), but treat it as per-app config rather than a global token.canon/auth-guards.md (MANDATORY) — every login-gated page must have a server-side beforeLoad route guard that redirects signed-out users to sign-in. A signed-out visitor must never render a gated page; API 401s and client-side <Protect> are not sufficient. Classify every route as gated or public./ro:workos for B2B-at-scale (100K+ MAU expected, partner needs WorkOS Admin Portal, near-term SAML SSO)/ro:better-auth for own-the-table cases (RLS, FKs, EU residency mandate, fully custom flows)/ro:nango when wiring third-party integrations (Nango sessions are scoped to your authenticated end-user)/ro:stripe when wiring payments (Stripe customers are linked to Clerk user IDs via metadata.clerk_user_id)/ro:design-system-create --showcase to scaffold the /styleguide route that consumes requireRole('superadmin', 'staff')/ro:new-tanstack-app --auth to scaffold a new app with Clerk pre-wired (default)/ro:cf-ship to ship after wiringllm-wiki-research/wiki/comparisons/auth-three-way-deep-dive.mddevelopment
--- name: worktree description: Coordinate multiple agents on one repo via a worktree-lock pool, so two agents never clobber each other's working tree. Acquire the first free slot (main, then beta/gamma… worktrees, created on demand), work there on your own branch, release when you've pushed. Use before modifying any repo that might be in use by another agent (factory, dataforce, etc.), or whenever you're told a repo is being worked on. Backed by `ro worktree`. category: development argument-hin
testing
--- name: ship description: Ship a feature branch the local-CI-first way — run the full local gate, push, open a PR, squash-merge, then deploy, without waiting on GitHub Actions. Use when a branch is ready for main and you want it merged and deployed now. Reads CI policy from `ro ci` (default skips remote CI because GitHub Actions billing keeps hitting limits). Sibling to /ro:gh-ship (waits on GitHub checks) and /ro:cf-ship (the deploy half). Triggers on "ship it", "ship this", "merge and deploy
testing
--- name: setup-logging description: Set up (or audit) the observability stack in a TanStack Start + Cloudflare Workers app so it is "diagnosable by default" — structured logging (logtape) with a request context carrying trace_id + userId + tenant/orgId, a trace_id propagated FE→BE→logs→Sentry→PostHog, Cloudflare Workers observability enabled, and Sentry + PostHog wired. Two modes: `setup` (wire it into an app) and `audit` (check an existing app + report gaps). Use when scaffolding a new app, wh
development
Manage credentials INSIDE the active ~/.claude/.env file — read which token/account to use for a given app (Simplicity vs Dataforce vs Ronan-personal), add or update a secret WITHOUT it passing through the chat (an interactive Terminal window prompts for it), and track secrets that were exposed in a transcript so they get rotated. Sibling to /ro:context (which switches WHICH env file is active). Use when the user wants to add an API key/token/secret, asks "which credential do I use for X", needs the env organized/labelled, or a secret was pasted into the chat and should be rotated.