clerk-auth

SKILL.md

Clerk Authentication Skill

Overview

This skill provides patterns for integrating Clerk authentication with the RFP Discovery platform and Convex backend.

Setup

Install Dependencies

npm install @clerk/clerk-react convex

Environment Variables

# .env.local (client-side)
VITE_CLERK_PUBLISHABLE_KEY=pk_test_...
VITE_CONVEX_URL=https://your-project.convex.cloud

# Convex Dashboard (server-side)
CLERK_ISSUER_URL=https://your-clerk-domain.clerk.accounts.dev

Clerk Dashboard Configuration

  1. Create application at https://dashboard.clerk.com
  2. Configure sign-in methods (Email, Google, GitHub)
  3. Create JWT template for Convex:
    • Name: convex
    • Claims:
      {
        "aud": "convex",
        "sub": "{{user.id}}",
        "name": "{{user.full_name}}",
        "email": "{{user.primary_email_address}}",
        "picture": "{{user.image_url}}"
      }
      

Convex Auth Config

// convex/auth.config.ts
export default {
  providers: [
    {
      domain: process.env.CLERK_ISSUER_URL,
      applicationID: "convex",
    },
  ],
};

Provider Setup

App Entry Point

// src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { ClerkProvider, useAuth } from "@clerk/clerk-react";
import { ConvexProviderWithClerk } from "convex/react-clerk";
import { ConvexReactClient } from "convex/react";
import App from "./App";

const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <ClerkProvider publishableKey={import.meta.env.VITE_CLERK_PUBLISHABLE_KEY}>
      <ConvexProviderWithClerk client={convex} useAuth={useAuth}>
        <App />
      </ConvexProviderWithClerk>
    </ClerkProvider>
  </React.StrictMode>
);

Authentication Components

Sign In/Out Buttons

// components/AuthButtons.tsx
import {
  SignedIn,
  SignedOut,
  SignInButton,
  SignUpButton,
  UserButton,
} from "@clerk/clerk-react";

export function AuthButtons() {
  return (
    <div className="flex items-center gap-4">
      <SignedOut>
        <SignInButton mode="modal">
          <button className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground">
            Sign In
          </button>
        </SignInButton>
        <SignUpButton mode="modal">
          <button className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90">
            Sign Up
          </button>
        </SignUpButton>
      </SignedOut>
      <SignedIn>
        <UserButton
          afterSignOutUrl="/"
          appearance={{
            elements: {
              avatarBox: "w-10 h-10",
            },
          }}
        />
      </SignedIn>
    </div>
  );
}

Protected Route Component

// components/ProtectedRoute.tsx
import { useAuth } from "@clerk/clerk-react";
import { Navigate, useLocation } from "react-router-dom";

interface ProtectedRouteProps {
  children: React.ReactNode;
  requiredRole?: "admin" | "user";
}

export function ProtectedRoute({ children, requiredRole }: ProtectedRouteProps) {
  const { isLoaded, isSignedIn } = useAuth();
  const location = useLocation();

  if (!isLoaded) {
    return (
      <div className="flex items-center justify-center h-screen">
        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
      </div>
    );
  }

  if (!isSignedIn) {
    return <Navigate to="/sign-in" state={{ from: location }} replace />;
  }

  // Role check would use Convex query here
  return <>{children}</>;
}

Auth Guard (Simple)

// components/AuthGuard.tsx
import { SignedIn, SignedOut, RedirectToSignIn } from "@clerk/clerk-react";

export function AuthGuard({ children }: { children: React.ReactNode }) {
  return (
    <>
      <SignedIn>{children}</SignedIn>
      <SignedOut>
        <RedirectToSignIn />
      </SignedOut>
    </>
  );
}

Convex Auth Patterns

User Identity in Mutations

// convex/pursuits.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";

export const create = mutation({
  args: { rfpId: v.id("rfps") },
  handler: async (ctx, args) => {
    // Always check auth first
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) {
      throw new Error("Not authenticated");
    }

    return await ctx.db.insert("pursuits", {
      rfpId: args.rfpId,
      userId: identity.subject, // Clerk user ID
      userName: identity.name ?? "Unknown",
      userEmail: identity.email ?? "",
      status: "new",
      createdAt: Date.now(),
      updatedAt: Date.now(),
    });
  },
});

User Sync on First Sign-In

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

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

    const existing = await ctx.db
      .query("users")
      .withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject))
      .first();

    if (existing) {
      // Update existing user
      await ctx.db.patch(existing._id, {
        name: identity.name ?? existing.name,
        email: identity.email ?? existing.email,
        imageUrl: identity.pictureUrl,
        updatedAt: Date.now(),
      });
      return existing._id;
    }

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

export const getCurrentUser = 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))
      .first();
  },
});

Auth Helper Functions

// convex/lib/auth.ts
import { QueryCtx, MutationCtx } from "../_generated/server";

export async function requireAuth(ctx: QueryCtx | MutationCtx) {
  const identity = await ctx.auth.getUserIdentity();
  if (!identity) {
    throw new Error("Not authenticated");
  }
  return identity;
}

export async function requireAdmin(ctx: QueryCtx | MutationCtx) {
  const identity = await requireAuth(ctx);

  const user = await ctx.db
    .query("users")
    .withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject))
    .first();

  if (!user || user.role !== "admin") {
    throw new Error("Admin access required");
  }

  return { identity, user };
}

export async function getOptionalUser(ctx: QueryCtx) {
  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))
    .first();
}

Admin-Only Mutation

// convex/admin.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { requireAdmin } from "./lib/auth";

export const deleteRfp = mutation({
  args: { rfpId: v.id("rfps") },
  handler: async (ctx, args) => {
    await requireAdmin(ctx); // Throws if not admin

    await ctx.db.delete(args.rfpId);
    return { success: true };
  },
});

export const updateUserRole = mutation({
  args: {
    userId: v.id("users"),
    role: v.string(),
  },
  handler: async (ctx, args) => {
    const { user: adminUser } = await requireAdmin(ctx);

    // Prevent self-demotion
    if (args.userId === adminUser._id) {
      throw new Error("Cannot change your own role");
    }

    await ctx.db.patch(args.userId, {
      role: args.role,
      updatedAt: Date.now(),
    });

    return { success: true };
  },
});

React Hooks

useCurrentUser Hook

// hooks/useCurrentUser.ts
import { useQuery } from "convex/react";
import { useUser, useAuth } from "@clerk/clerk-react";
import { api } from "../convex/_generated/api";

export function useCurrentUser() {
  const { user: clerkUser, isLoaded: clerkLoaded } = useUser();
  const { isSignedIn } = useAuth();
  const convexUser = useQuery(
    api.users.getCurrentUser,
    isSignedIn ? {} : "skip"
  );

  return {
    clerkUser,
    convexUser,
    isLoaded: clerkLoaded && (convexUser !== undefined || !isSignedIn),
    isSignedIn: !!clerkUser,
    isAdmin: convexUser?.role === "admin",
    userId: convexUser?._id,
  };
}

Auto-Sync User Hook

// hooks/useSyncUser.ts
import { useEffect } from "react";
import { useMutation } from "convex/react";
import { useAuth } from "@clerk/clerk-react";
import { api } from "../convex/_generated/api";

export function useSyncUser() {
  const { isSignedIn, isLoaded } = useAuth();
  const syncUser = useMutation(api.users.syncUser);

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

// Use in App.tsx
function App() {
  useSyncUser(); // Syncs user on sign-in

  return <AppContent />;
}

Header Integration

// components/Header.tsx
import { AuthButtons } from "./AuthButtons";
import { useCurrentUser } from "../hooks/useCurrentUser";

export function Header() {
  const { convexUser, isAdmin, isLoaded } = useCurrentUser();

  return (
    <header className="flex items-center justify-between p-4 border-b border-border">
      <div className="flex items-center gap-4">
        <h1 className="text-xl font-bold">RFP Discovery</h1>
        {isAdmin && (
          <span className="px-2 py-1 text-xs bg-primary/20 text-primary rounded">
            Admin
          </span>
        )}
      </div>
      <div className="flex items-center gap-4">
        {isLoaded && convexUser && (
          <span className="text-sm text-muted-foreground">
            {convexUser.name}
          </span>
        )}
        <AuthButtons />
      </div>
    </header>
  );
}

Role-Based UI

// components/AdminSection.tsx
import { useCurrentUser } from "../hooks/useCurrentUser";

export function AdminSection({ children }: { children: React.ReactNode }) {
  const { isAdmin, isLoaded } = useCurrentUser();

  if (!isLoaded) return null;
  if (!isAdmin) return null;

  return <>{children}</>;
}

// Usage
function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>

      {/* Visible to all */}
      <RfpList />

      {/* Admin only */}
      <AdminSection>
        <AdminControls />
      </AdminSection>
    </div>
  );
}

Common Patterns Summary

Pattern Use Case
SignedIn / SignedOut Conditional rendering based on auth
useAuth().isSignedIn Check auth state in hooks
ctx.auth.getUserIdentity() Get user in Convex functions
requireAuth(ctx) Throw if not authenticated
requireAdmin(ctx) Throw if not admin
User sync mutation Keep Convex user in sync with Clerk
Weekly Installs
3
First Seen
Feb 26, 2026
Installed on
gemini-cli3
github-copilot3
codex3
amp3
kimi-cli3
openclaw3