VOOZH about

URL: https://dev.to/nareshipme/building-toolnexus-wiring-clerk-auth-to-supabase-with-tdd-1acl

⇱ How to Sync Clerk Users to Supabase with Webhooks (TDD Approach) - DEV Community


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.created and user.updated
  • No race conditions — database handles conflict resolution

Summary

  1. Write failing tests first (TDD)
  2. Map Clerk user → DB shape with a pure function (easy to test)
  3. Use Supabase upsert with onConflict for idempotency
  4. Verify Svix signatures on the webhook route
  5. Add runtime = 'nodejs' for crypto compatibility in Next.js 15

Stack: Next.js 15 · Clerk · Supabase · TypeScript · Vitest