The Problem
Clerk handles authentication beautifully, but your app logic lives in Supabase. Every time a user signs up or updates their profile, you need a corresponding row in your users table.
The solution: a Clerk webhook → /api/webhooks/clerk → upsert into Supabase.
Red First: Write Failing Tests
// src/lib/__tests__/auth-sync.test.ts
describe("mapClerkUserToDb", () => {
it("maps basic user fields", () => {
const clerkUser = {
id: "user_123",
emailAddresses: [{ emailAddress: "test@example.com" }],
firstName: "John",
lastName: "Doe",
};
const result = mapClerkUserToDb(clerkUser);
expect(result).toEqual({
clerk_id: "user_123",
email: "test@example.com",
full_name: "John Doe",
plan: "free",
credits: 30,
});
});
});
Tests fail first. That's the point.
Green: Implement auth-sync.ts
export function mapClerkUserToDb(clerkUser: ClerkUser): DbUser {
const email = clerkUser.emailAddresses[0]?.emailAddress ?? "";
const firstName = clerkUser.firstName ?? "";
const lastName = clerkUser.lastName ?? "";
const full_name = [firstName, lastName].filter(Boolean).join(" ") || email;
return {
clerk_id: clerkUser.id,
email,
full_name,
plan: "free",
credits: 30,
};
}
export async function upsertUserFromClerk(clerkUser: ClerkUser): Promise<void> {
const userData = mapClerkUserToDb(clerkUser);
const { error } = await supabaseAdmin
.from("users")
.upsert(userData, { onConflict: "clerk_id" });
if (error) throw new Error(`Failed to upsert user: ${error.message}`);
}
The upsert with onConflict: "clerk_id" handles both new signups and profile updates in one query — clean and idempotent.
Supabase Schema with RLS
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
clerk_id TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
full_name TEXT,
plan TEXT DEFAULT 'free',
credits INTEGER DEFAULT 30,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Row Level Security
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own data"
ON users FOR SELECT
USING (clerk_id = current_setting('app.clerk_user_id', true));
The Webhook Route
The /api/webhooks/clerk route listens for user.created and user.updated events, verifies the Svix signature, and calls upsertUserFromClerk.
One gotcha: Next.js 15 App Router requires export const runtime = 'nodejs' on webhook routes that use the Node.js crypto APIs Svix depends on.
Why Upsert?
Using upsert instead of separate insert/update logic means:
- Idempotent — safe to replay webhook events
-
One query — handles both
user.createdanduser.updated - No race conditions — database handles conflict resolution
Summary
- Write failing tests first (TDD)
- Map Clerk user → DB shape with a pure function (easy to test)
- Use Supabase
upsertwithonConflictfor idempotency - Verify Svix signatures on the webhook route
- Add
runtime = 'nodejs'for crypto compatibility in Next.js 15
Stack: Next.js 15 · Clerk · Supabase · TypeScript · Vitest
Top comments (0)