DEV Community

nareshipme
nareshipme

Posted on

How to Sync Clerk Users to Supabase with Webhooks (TDD Approach)

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,
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)