Skill: michaelmonetized/sr-next-clerk-expert (version 1.0.1)

--- name: sr-next-clerk-expert description: Senior-level Clerk authentication expertise for Next.js 15/16+ applications. Use when implementing auth, protecting routes, fixing auth errors (500s, handshake redirects, middleware failures), integrating with Convex/Stripe, or debugging Clerk issues. Covers proxy.ts patterns, route groups, client vs server auth, and the 12 Commandments that prevent common disasters. env: required: - CLERK_SECRET_KEY - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY optional: - CLERK_WEBHOOK_SECRET - STRIPE_SECRET_KEY - NEXT_PUBLIC_CONVEX_URL - CONVEX_DEPLOYMENT --- # Senior Next.js + Clerk Expert You are a senior engineer implementing Clerk authentication. Follow these patterns exactly—deviations cause production outages. --- ## ⚠️ CRITICAL: THE TWELVE COMMANDMENTS These rules are non-negotiable. Violations cause 500 errors, infinite redirects, and broken sites. | # | Commandment | Violation Consequence | |---|-------------|----------------------| | I | Use `app/(private)/` route groups | Maintenance hell, broken auth | | II | Keep proxy.ts simple (protect only private) | Every new page needs proxy update | | III | NEVER call `auth()` on public pages | 500 errors, slow pages, SEO death | | IV | Use `<SignedIn>`/`<SignedOut>` for conditional content | Server errors on static pages | | V | Wrap Clerk components in `<ClerkLoaded>` | Flash of wrong content | | VI | Pair `<ClerkLoaded>` with `<ClerkLoading>` | Jarring loading states | | VII | Configure redirects in ClerkProvider | Redirect loops | | VIII | No handshake redirects on public pages | Broken user experience | | IX | Keep marketing pages STATIC | Slow pages, bad SEO | | X | Verify env vars EXACTLY (copy-paste only) | Cryptic 500 errors | | XI | Use `proxy.ts` not `middleware.ts` (Next.js 16+) | Deprecation warnings | | XII | Test as anonymous user before deploy | Ship broken auth | --- ## Quick Reference ### Project Structure ``` app/ ├── (private)/ # Protected - requires auth │ ├── dashboard/ │ ├── settings/ │ └── layout.tsx # Can call auth() here ├── page.tsx # PUBLIC - NO auth() ├── layout.tsx # Root - ClerkProvider ├── sign-in/[[...sign-in]]/page.tsx └── sign-up/[[...sign-up]]/page.tsx ``` ### The ONLY Correct proxy.ts ```typescript import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; const isPrivateRoute = createRouteMatcher(["/(private)(.*)"]); export default clerkMiddleware(async (auth, request) => { if (isPrivateRoute(request)) { await auth.protect(); } }); export const config = { matcher: [ "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", "/(api|trpc)(.*)", ], }; ``` --- ## Patterns by Use Case ### Public Page with Auth-Conditional Content ```tsx // app/page.tsx - CORRECT import { ClerkLoaded, ClerkLoading, SignedIn, SignedOut } from "@clerk/nextjs"; export default function HomePage() { return ( <main> <h1>Welcome</h1> <ClerkLoading> <Skeleton /> </ClerkLoading> <ClerkLoaded> <SignedOut> <a href="/sign-in">Sign In</a> </SignedOut> <SignedIn> <a href="/dashboard">Dashboard</a> </SignedIn> </ClerkLoaded> </main> ); } ``` ### Private Layout (Route Protection) ```tsx // app/(private)/layout.tsx import { auth } from "@clerk/nextjs/server"; import { redirect } from "next/navigation"; export default async function PrivateLayout({ children }: { children: React.ReactNode }) { const { userId } = await auth(); if (!userId) redirect("/sign-in"); return <>{children}</>; } ``` ### Root Layout with ClerkProvider ```tsx // app/layout.tsx import { ClerkProvider } from "@clerk/nextjs"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <ClerkProvider signInUrl="/sign-in" signUpUrl="/sign-up" afterSignInUrl="/dashboard" afterSignUpUrl="/dashboard" > <html lang="en"> <body>{children}</body> </html> </ClerkProvider> ); } ``` --- ## Advanced Patterns For complex integrations, see reference files: - **Convex Integration**: See [references/convex.md](references/convex.md) for ConvexProviderWithClerk patterns - **Stripe/Billing**: See [references/stripe.md](references/stripe.md) for subscription flows with Clerk - **Multi-tenant/Organizations**: See [references/organizations.md](references/organizations.md) for org-based auth - **Webhooks**: See [references/webhooks.md](references/webhooks.md) for user sync patterns - **Custom Sign-in Pages**: See [references/custom-ui.md](references/custom-ui.md) for branded auth pages - **Debugging Guide**: See [references/debugging.md](references/debugging.md) for fixing common errors --- ## Environment Variables ```bash # .env.local - COPY FROM CLERK DASHBOARD (do not type manually) NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_... CLERK_SECRET_KEY=sk_test_... # Optional redirects 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=/dashboard ``` **⚠️ CRITICAL**: Copy-paste keys from Clerk dashboard. Manual typing causes `1/l` and `x/X` errors that produce cryptic 500s. --- ## Common Errors & Fixes | Error | Cause | Fix | |-------|-------|-----| | `MIDDLEWARE_INVOCATION_FAILED` | Missing/wrong CLERK_SECRET_KEY | Re-copy from dashboard | | `?__clerk_handshake=` in URL | `auth()` called on public page | Remove auth(), use SignedIn/SignedOut | | Infinite redirect loop | Missing/wrong redirect config | Set afterSignInUrl in ClerkProvider | | 500 on homepage | Server-side auth on static page | Make page client-side or remove auth | | Flash of wrong content | Missing ClerkLoaded wrapper | Wrap Clerk components | --- ## Anti-Patterns (NEVER DO) ```tsx // ❌ WRONG - auth() on public page export default async function HomePage() { const { userId } = await auth(); // BREAKS STATIC RENDERING if (userId) redirect("/dashboard"); return <LandingPage />; } // ❌ WRONG - listing every public route const isPublicRoute = createRouteMatcher([ "/", "/about", "/pricing", "/blog", "/contact", // MAINTENANCE HELL ]); // ❌ WRONG - no ClerkLoaded wrapper <SignedIn> <UserButton /> // FLASHES INCORRECTLY </SignedIn> // ❌ WRONG - middleware.ts in Next.js 16+ // File: middleware.ts // DEPRECATED - USE proxy.ts ``` --- ## Migration: middleware.ts → proxy.ts ```bash # Option 1: Rename mv middleware.ts proxy.ts # Option 2: Codemod npx @next/codemod@latest middleware-to-proxy ``` --- ## 🔐 Security Best Practices ### Secret Management - **Store secrets in platform env vars** (Vercel, Railway, etc.) — never in code or git - **Use separate keys for dev/staging/prod** — Clerk provides different instances - **Rotate keys if compromised** — Clerk Dashboard → API Keys → Add new key → update env → delete old - **Limit access** — only team members who need keys should have dashboard access ### Key Rotation Procedure 1. Create new key in Clerk Dashboard 2. Update production env var (Vercel: `vercel env rm CLERK_SECRET_KEY production && vercel env add CLERK_SECRET_KEY production`) 3. Redeploy 4. Verify auth works 5. Delete old key from Clerk Dashboard ### Webhook Security - **Always verify signatures** — use `svix` library (shown in references/webhooks.md) - **Use HTTPS endpoints only** — never expose webhook URLs over HTTP - **Store CLERK_WEBHOOK_SECRET securely** — same as other secrets ### Debug Logging ⚠️ **NEVER use debug mode in production:** ```typescript // ❌ REMOVE BEFORE DEPLOYING export default clerkMiddleware( async (auth, request) => { /* ... */ }, { debug: true } // LEAKS TOKENS TO LOGS ); ``` Debug mode logs handshake tokens (`?__clerk_handshake=`) which are sensitive. Use only in local development. ### Least Privilege | Secret | Scope | Notes | |--------|-------|-------| | `CLERK_SECRET_KEY` | Server only | Never expose to client | | `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Client safe | Can be in client bundles | | `CLERK_WEBHOOK_SECRET` | Server only | Webhook handler only | | `STRIPE_SECRET_KEY` | Server only | API routes only | --- ## Verification Checklist Before deploying, verify: - [ ] `proxy.ts` exists (not `middleware.ts`) - [ ] proxy.ts ONLY protects `/(private)` routes - [ ] No `auth()` calls in `app/page.tsx` or marketing pages - [ ] All Clerk components wrapped in `<ClerkLoaded>` - [ ] `<ClerkLoading>` shows skeleton/spinner - [ ] Env vars copied exactly from Clerk dashboard - [ ] Anonymous user can access homepage (incognito test) - [ ] Sign-in redirects to correct page - [ ] Dashboard requires authentication

path: references/convex.md
content: # Clerk + Convex Integration

## Provider Setup

```tsx
// providers/convex-clerk-provider.tsx
"use client";

import { ClerkProvider, useAuth } from "@clerk/nextjs";
import { ConvexProviderWithClerk } from "convex/react-clerk";
import { ConvexReactClient } from "convex/react";
import { ReactNode } from "react";

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);

export function ConvexClerkProvider({ children }: { children: ReactNode }) {
  return (
    <ClerkProvider
      signInUrl="/sign-in"
      signUpUrl="/sign-up"
      afterSignInUrl="/dashboard"
      afterSignUpUrl="/dashboard"
    >
      <ConvexProviderWithClerk client={convex} useAuth={useAuth}>
        {children}
      </ConvexProviderWithClerk>
    </ClerkProvider>
  );
}
```

## Root Layout

```tsx
// app/layout.tsx
import { ConvexClerkProvider } from "@/providers/convex-clerk-provider";

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <ConvexClerkProvider>
      <html lang="en">
        <body>{children}</body>
      </html>
    </ConvexClerkProvider>
  );
}
```

## User Sync Pattern (Webhook-free)

Sync Clerk user to Convex on first authenticated action:

```typescript
// convex/users.ts
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";

export const getOrCreateUser = mutation({
  args: {},
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Not authenticated");

    // Check if user exists
    const existing = await ctx.db
      .query("users")
      .withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject))
      .unique();

    if (existing) return existing._id;

    // Create new user
    return await ctx.db.insert("users", {
      clerkId: identity.subject,
      email: identity.email!,
      name: identity.name ?? "",
      imageUrl: identity.pictureUrl ?? "",
      createdAt: Date.now(),
    });
  },
});

export const currentUser = query({
  args: {},
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) return null;

    return await ctx.db
      .query("users")
      .withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject))
      .unique();
  },
});
```

## Schema

```typescript
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  users: defineTable({
    clerkId: v.string(),
    email: v.string(),
    name: v.string(),
    imageUrl: v.string(),
    createdAt: v.number(),
  })
    .index("by_clerk_id", ["clerkId"])
    .index("by_email", ["email"]),
});
```

## Client-Side User Sync

```tsx
// components/user-sync.tsx
"use client";

import { useUser } from "@clerk/nextjs";
import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
import { useEffect } from "react";

export function UserSync({ children }: { children: React.ReactNode }) {
  const { isSignedIn, isLoaded } = useUser();
  const syncUser = useMutation(api.users.getOrCreateUser);

  useEffect(() => {
    if (isLoaded && isSignedIn) {
      syncUser().catch(console.error);
    }
  }, [isLoaded, isSignedIn, syncUser]);

  return <>{children}</>;
}
```

## Protected Convex Queries

```typescript
// convex/documents.ts
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";

export const list = query({
  args: {},
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) return [];

    return await ctx.db
      .query("documents")
      .withIndex("by_owner", (q) => q.eq("ownerId", identity.subject))
      .collect();
  },
});

export const create = mutation({
  args: { title: v.string() },
  handler: async (ctx, { title }) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Not authenticated");

    return await ctx.db.insert("documents", {
      title,
      ownerId: identity.subject,
      createdAt: Date.now(),
    });
  },
});
```


path: references/custom-ui.md
content: # Custom Sign-In/Sign-Up Pages

## Basic Custom Pages

```tsx
// app/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from "@clerk/nextjs";

export default function SignInPage() {
  return (
    <div className="min-h-screen flex items-center justify-center bg-background">
      <SignIn
        appearance={{
          elements: {
            rootBox: "mx-auto",
            card: "bg-card shadow-lg",
          },
        }}
      />
    </div>
  );
}
```

```tsx
// app/sign-up/[[...sign-up]]/page.tsx
import { SignUp } from "@clerk/nextjs";

export default function SignUpPage() {
  return (
    <div className="min-h-screen flex items-center justify-center bg-background">
      <SignUp
        appearance={{
          elements: {
            rootBox: "mx-auto",
            card: "bg-card shadow-lg",
          },
        }}
      />
    </div>
  );
}
```

## Theming with @clerk/themes

```bash
bun add @clerk/themes
```

```tsx
// app/layout.tsx
import { ClerkProvider } from "@clerk/nextjs";
import { dark } from "@clerk/themes";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <ClerkProvider
      appearance={{
        baseTheme: dark,
        variables: {
          colorPrimary: "#8b5cf6",
          colorBackground: "#0a0a0a",
          colorInputBackground: "#1a1a1a",
          colorInputText: "#ffffff",
        },
        elements: {
          formButtonPrimary: "bg-primary hover:bg-primary/90",
          card: "bg-card border border-border",
          headerTitle: "text-foreground",
          headerSubtitle: "text-muted-foreground",
          socialButtonsBlockButton: "border-border",
          formFieldInput: "bg-input border-border",
          footerActionLink: "text-primary hover:text-primary/80",
        },
      }}
    >
      {children}
    </ClerkProvider>
  );
}
```

## Catppuccin Theme

```tsx
// lib/clerk-theme.ts
import { dark } from "@clerk/themes";

export const catppuccinTheme = {
  baseTheme: dark,
  variables: {
    // Mocha palette
    colorPrimary: "#cba6f7",      // Mauve
    colorBackground: "#1e1e2e",   // Base
    colorInputBackground: "#313244", // Surface0
    colorInputText: "#cdd6f4",    // Text
    colorText: "#cdd6f4",         // Text
    colorTextSecondary: "#a6adc8", // Subtext0
    colorDanger: "#f38ba8",       // Red
    colorSuccess: "#a6e3a1",      // Green
    colorWarning: "#f9e2af",      // Yellow
    borderRadius: "0.5rem",
  },
  elements: {
    card: "bg-[#1e1e2e] border border-[#313244]",
    headerTitle: "text-[#cdd6f4]",
    headerSubtitle: "text-[#a6adc8]",
    formButtonPrimary: "bg-[#cba6f7] hover:bg-[#cba6f7]/90 text-[#1e1e2e]",
    formFieldInput: "bg-[#313244] border-[#45475a] text-[#cdd6f4]",
    formFieldLabel: "text-[#cdd6f4]",
    footerActionLink: "text-[#cba6f7] hover:text-[#cba6f7]/80",
    identityPreviewEditButton: "text-[#cba6f7]",
    socialButtonsBlockButton: "border-[#45475a] text-[#cdd6f4]",
    dividerLine: "bg-[#45475a]",
    dividerText: "text-[#6c7086]",
  },
};
```

## Branded Sign-In Page

```tsx
// app/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from "@clerk/nextjs";
import Image from "next/image";

export default function SignInPage() {
  return (
    <div className="min-h-screen grid lg:grid-cols-2">
      {/* Left: Branding */}
      <div className="hidden lg:flex flex-col justify-center p-12 bg-primary">
        <Image src="/logo-white.svg" alt="Logo" width={200} height={50} />
        <h1 className="mt-8 text-4xl font-bold text-white">
          Welcome back
        </h1>
        <p className="mt-4 text-white/80">
          Sign in to access your dashboard and manage your projects.
        </p>
      </div>

      {/* Right: Sign-in form */}
      <div className="flex items-center justify-center p-8">
        <SignIn
          appearance={{
            elements: {
              rootBox: "w-full max-w-md",
              card: "shadow-none p-0",
            },
          }}
        />
      </div>
    </div>
  );
}
```

## Custom UserButton Menu

```tsx
<UserButton afterSignOutUrl="/">
  <UserButton.MenuItems>
    <UserButton.Link
      label="Dashboard"
      href="/dashboard"
      labelIcon={<LayoutDashboard className="w-4 h-4" />}
    />
    <UserButton.Link
      label="Settings"
      href="/settings"
      labelIcon={<Settings className="w-4 h-4" />}
    />
    <UserButton.Action label="manageAccount" />
    <UserButton.Action label="signOut" />
  </UserButton.MenuItems>
</UserButton>
```


path: references/debugging.md
content: # Clerk Debugging Guide

## Error: MIDDLEWARE_INVOCATION_FAILED (500)

**Symptoms:**
- Site returns 500 error
- Vercel logs show `MIDDLEWARE_INVOCATION_FAILED`
- Error mentions "secret key invalid"

**Causes:**
1. Missing `CLERK_SECRET_KEY` env var on Vercel
2. Wrong key (typo, `1` vs `l`, `x` vs `X`)
3. Key from wrong Clerk app/environment

**Fix:**
```bash
# 1. Go to Clerk Dashboard → API Keys
# 2. Click "Reveal Secret Key"
# 3. Click the COPY BUTTON (don't manually select)
# 4. Add to Vercel
vercel env add CLERK_SECRET_KEY production
# 5. Paste exactly
# 6. Redeploy
vercel --prod
```

---

## Error: Handshake Redirect Loop

**Symptoms:**
- URL contains `?__clerk_handshake=...`
- Page keeps redirecting
- Works when signed in, breaks when signed out

**Cause:** `auth()` called on a public page

**Fix:**
```tsx
// ❌ WRONG
export default async function HomePage() {
  const { userId } = await auth();
  // ...
}

// ✅ CORRECT
export default function HomePage() {
  return (
    <ClerkLoaded>
      <SignedIn>{/* authenticated content */}</SignedIn>
      <SignedOut>{/* public content */}</SignedOut>
    </ClerkLoaded>
  );
}
```

---

## Error: Infinite Redirect After Sign-In

**Symptoms:**
- Sign in succeeds but redirects back to sign-in
- Browser shows redirect loop error

**Causes:**
1. `afterSignInUrl` points to protected route that redirects back
2. Missing user in database (Convex integration)
3. Middleware blocking the redirect target

**Fix:**
```tsx
// Ensure afterSignInUrl goes to an accessible page
<ClerkProvider afterSignInUrl="/dashboard">

// Ensure /dashboard layout doesn't redirect signed-in users
// app/(private)/layout.tsx
export default async function Layout({ children }) {
  const { userId } = await auth();
  if (!userId) redirect("/sign-in");  // Only redirect if NOT signed in
  return <>{children}</>;
}
```

---

## Error: Flash of Unauthenticated Content

**Symptoms:**
- Sign-in button briefly shows for authenticated users
- Content flickers on page load

**Cause:** Missing `<ClerkLoaded>` wrapper

**Fix:**
```tsx
// ❌ WRONG
<SignedIn>
  <UserButton />
</SignedIn>

// ✅ CORRECT
<ClerkLoading>
  <Skeleton className="w-8 h-8 rounded-full" />
</ClerkLoading>
<ClerkLoaded>
  <SignedIn>
    <UserButton />
  </SignedIn>
</ClerkLoaded>
```

---

## Error: "Client created with undefined deployment address"

**Symptoms:**
- Build fails with Convex error
- Works locally but not on Vercel

**Cause:** Missing Convex env vars on Vercel

**Fix:**
```bash
vercel env add CONVEX_DEPLOYMENT production
vercel env add NEXT_PUBLIC_CONVEX_URL production
vercel --prod
```

---

## Debug Mode

⚠️ **LOCAL DEVELOPMENT ONLY** — Never enable in production!

Debug mode logs sensitive handshake tokens that could be exploited.

```typescript
// FOR LOCAL DEV ONLY - REMOVE BEFORE DEPLOYING
export default clerkMiddleware(
  async (auth, request) => {
    if (process.env.NODE_ENV === 'development') {
      console.log("Auth request:", request.url);
    }
    // ... your logic
  },
  { debug: process.env.NODE_ENV === 'development' }
);
```

**Before deploying:**
1. Remove `{ debug: true }`
2. Remove any `console.log` that logs request URLs
3. These can expose `?__clerk_handshake=` tokens in logs

---

## Verification Steps

1. **Test as anonymous user:**
   - Open incognito window
   - Visit homepage → should load without redirect
   - Visit `/dashboard` → should redirect to sign-in

2. **Test sign-in flow:**
   - Sign in → should redirect to dashboard
   - Refresh dashboard → should stay on dashboard
   - Click sign out → should redirect to homepage

3. **Check console:**
   - No Clerk errors
   - No hydration mismatches
   - No 401/403 errors in Network tab

4. **Check Clerk dashboard:**
   - Sessions tab shows activity
   - No errors in Logs tab


path: references/organizations.md
content: # Clerk Organizations (Multi-tenant)

## Enable Organizations

1. Clerk Dashboard → Configure → Organizations → Enable
2. Configure roles (admin, member, etc.)

## Organization Switcher

```tsx
// components/org-switcher.tsx
"use client";

import { OrganizationSwitcher, ClerkLoaded, ClerkLoading } from "@clerk/nextjs";

export function OrgSwitcher() {
  return (
    <>
      <ClerkLoading>
        <div className="w-48 h-10 bg-muted animate-pulse rounded" />
      </ClerkLoading>
      <ClerkLoaded>
        <OrganizationSwitcher
          afterCreateOrganizationUrl="/dashboard"
          afterSelectOrganizationUrl="/dashboard"
          afterLeaveOrganizationUrl="/dashboard"
        />
      </ClerkLoaded>
    </>
  );
}
```

## Get Active Organization

```typescript
// Server Component
import { auth } from "@clerk/nextjs/server";

export default async function DashboardPage() {
  const { orgId, orgRole, orgSlug } = await auth();
  
  if (!orgId) {
    return <CreateOrgPrompt />;
  }

  return <OrgDashboard orgId={orgId} role={orgRole} />;
}
```

```tsx
// Client Component
"use client";

import { useOrganization, useOrganizationList } from "@clerk/nextjs";

export function OrgInfo() {
  const { organization, membership } = useOrganization();
  const { userMemberships } = useOrganizationList();

  if (!organization) return <div>No organization selected</div>;

  return (
    <div>
      <h2>{organization.name}</h2>
      <p>Role: {membership?.role}</p>
      <p>Members: {organization.membersCount}</p>
    </div>
  );
}
```

## Role-Based Access

```tsx
// app/(private)/admin/page.tsx
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";

export default async function AdminPage() {
  const { orgRole } = await auth();

  if (orgRole !== "org:admin") {
    redirect("/dashboard");
  }

  return <AdminDashboard />;
}
```

## Protect Component

```tsx
import { Protect } from "@clerk/nextjs";

export function AdminSection() {
  return (
    <Protect role="org:admin" fallback={<div>Admin access required</div>}>
      <AdminControls />
    </Protect>
  );
}
```

## Organization Metadata

```typescript
// Set organization metadata
import { clerkClient } from "@clerk/nextjs/server";

await clerkClient.organizations.updateOrganization(orgId, {
  publicMetadata: {
    plan: "pro",
    features: ["analytics", "api"],
  },
});

// Read metadata
const { organization } = useOrganization();
const plan = organization?.publicMetadata?.plan;
```

## Convex with Organizations

```typescript
// convex/documents.ts
export const list = query({
  args: {},
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) return [];

    // Get org from JWT claims
    const orgId = identity.org_id;

    if (orgId) {
      // Org context - return org documents
      return await ctx.db
        .query("documents")
        .withIndex("by_org", (q) => q.eq("orgId", orgId))
        .collect();
    } else {
      // Personal context - return user documents
      return await ctx.db
        .query("documents")
        .withIndex("by_owner", (q) => q.eq("ownerId", identity.subject))
        .collect();
    }
  },
});
```


path: references/stripe.md
content: # Clerk + Stripe Billing Integration

## Architecture

```
User signs up (Clerk) → Webhook creates Stripe customer → User subscribes → Webhook updates user
```

## Clerk Webhook for Stripe Customer

```typescript
// app/api/webhooks/clerk/route.ts
import { Webhook } from "svix";
import { headers } from "next/headers";
import { WebhookEvent } from "@clerk/nextjs/server";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET!;
  const headerPayload = headers();
  const svix_id = headerPayload.get("svix-id");
  const svix_timestamp = headerPayload.get("svix-timestamp");
  const svix_signature = headerPayload.get("svix-signature");

  const body = await req.text();
  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) {
    return new Response("Invalid signature", { status: 400 });
  }

  if (evt.type === "user.created") {
    const { id, email_addresses, first_name, last_name } = evt.data;
    const email = email_addresses[0]?.email_address;

    // Create Stripe customer
    const customer = await stripe.customers.create({
      email,
      name: `${first_name} ${last_name}`.trim(),
      metadata: { clerkId: id },
    });

    // Store stripeCustomerId in your database
    // await db.users.update({ clerkId: id, stripeCustomerId: customer.id });
  }

  return new Response("OK", { status: 200 });
}
```

## Checkout Session

```typescript
// app/api/checkout/route.ts
import { auth, currentUser } from "@clerk/nextjs/server";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const { userId } = await auth();
  if (!userId) return new Response("Unauthorized", { status: 401 });

  const user = await currentUser();
  const { priceId } = await req.json();

  // Get or create Stripe customer
  // const stripeCustomerId = await getStripeCustomerId(userId);

  const session = await stripe.checkout.sessions.create({
    customer_email: user?.emailAddresses[0]?.emailAddress,
    mode: "subscription",
    payment_method_types: ["card"],
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing?canceled=true`,
    metadata: { clerkId: userId },
  });

  return Response.json({ url: session.url });
}
```

## Subscription Status Check

```typescript
// lib/subscription.ts
import { auth } from "@clerk/nextjs/server";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function getSubscription() {
  const { userId } = await auth();
  if (!userId) return null;

  // Get stripeCustomerId from your database
  // const stripeCustomerId = await getStripeCustomerId(userId);

  const subscriptions = await stripe.subscriptions.list({
    customer: stripeCustomerId,
    status: "active",
    limit: 1,
  });

  return subscriptions.data[0] ?? null;
}

export async function hasActiveSubscription(): Promise<boolean> {
  const subscription = await getSubscription();
  return subscription?.status === "active";
}
```

## Protected Billing Routes

```tsx
// app/(private)/billing/page.tsx
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import { getSubscription } from "@/lib/subscription";

export default async function BillingPage() {
  const { userId } = await auth();
  if (!userId) redirect("/sign-in");

  const subscription = await getSubscription();

  return (
    <div>
      {subscription ? (
        <SubscriptionDetails subscription={subscription} />
      ) : (
        <UpgradePrompt />
      )}
    </div>
  );
}
```

## Customer Portal

```typescript
// app/api/billing/portal/route.ts
import { auth } from "@clerk/nextjs/server";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST() {
  const { userId } = await auth();
  if (!userId) return new Response("Unauthorized", { status: 401 });

  // Get stripeCustomerId from your database
  // const stripeCustomerId = await getStripeCustomerId(userId);

  const session = await stripe.billingPortal.sessions.create({
    customer: stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_URL}/dashboard`,
  });

  return Response.json({ url: session.url });
}
```


path: references/webhooks.md
content: # Clerk Webhooks

## Setup

1. Go to Clerk Dashboard → Webhooks
2. Add endpoint: `https://your-app.com/api/webhooks/clerk`
3. Select events: `user.created`, `user.updated`, `user.deleted`
4. Copy signing secret to `CLERK_WEBHOOK_SECRET`

## Webhook Handler

```typescript
// 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("Missing CLERK_WEBHOOK_SECRET");
  }

  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 });
  }

  const body = await req.text();
  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 });
  }

  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;
  }

  return new Response("OK", { status: 200 });
}

async function handleUserCreated(data: any) {
  const { id, email_addresses, first_name, last_name, image_url } = data;
  const email = email_addresses[0]?.email_address;

  // Create user in your database
  // await db.users.create({
  //   clerkId: id,
  //   email,
  //   name: `${first_name ?? ""} ${last_name ?? ""}`.trim(),
  //   imageUrl: image_url,
  // });
}

async function handleUserUpdated(data: any) {
  const { id, email_addresses, first_name, last_name, image_url } = data;
  const email = email_addresses[0]?.email_address;

  // Update user in your database
  // await db.users.update({
  //   where: { clerkId: id },
  //   data: {
  //     email,
  //     name: `${first_name ?? ""} ${last_name ?? ""}`.trim(),
  //     imageUrl: image_url,
  //   },
  // });
}

async function handleUserDeleted(data: any) {
  const { id } = data;

  // Delete or soft-delete user
  // await db.users.delete({ where: { clerkId: id } });
}
```

## Convex Webhook Handler

```typescript
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { Webhook } from "svix";
import { internal } from "./_generated/api";

const http = httpRouter();

http.route({
  path: "/webhooks/clerk",
  method: "POST",
  handler: httpAction(async (ctx, request) => {
    const [REDACTED_SECRET];
    const svixId = request.headers.get("svix-id")!;
    const svixTimestamp = request.headers.get("svix-timestamp")!;
    const svixSignature = request.headers.get("svix-signature")!;

    const body = await request.text();
    const wh = new Webhook(secret);

    let evt: any;
    try {
      evt = wh.verify(body, {
        "svix-id": svixId,
        "svix-timestamp": svixTimestamp,
        "svix-signature": svixSignature,
      });
    } catch {
      return new Response("Invalid signature", { status: 400 });
    }

    switch (evt.type) {
      case "user.created":
        await ctx.runMutation(internal.users.createFromWebhook, {
          clerkId: evt.data.id,
          email: evt.data.email_addresses[0]?.email_address,
          name: `${evt.data.first_name ?? ""} ${evt.data.last_name ?? ""}`.trim(),
          imageUrl: evt.data.image_url,
        });
        break;
    }

    return new Response("OK", { status: 200 });
  }),
});

export default http;
```

## Testing Webhooks Locally

```bash
# Install ngrok
brew install ngrok

# Expose local server
ngrok http 3000

# Use ngrok URL in Clerk Dashboard webhook config
# https://abc123.ngrok.io/api/webhooks/clerk
```

## Required Dependencies

```bash
bun add svix
```


Security scan verdicts — ClawScan: clean | static analysis:  | VirusTotal: clean | SkillSpector: clean (LOW)