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
- Create application at https://dashboard.clerk.com
- Configure sign-in methods (Email, Google, GitHub)
- 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}}" }
- Name:
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
Repository
atemndobs/nebula-rfpFirst Seen
Feb 26, 2026
Security Audits
Installed on
gemini-cli3
github-copilot3
codex3
amp3
kimi-cli3
openclaw3