DEV Community

Mouheb Kraiem
Mouheb Kraiem

Posted on

How to Migrate from Clerk to Supabase Auth (Save $200+/month)

I keep seeing the same story in developer communities:

"My client spent $100-150 last month on Clerk for around 15,000 users."

"Our bill went from $240/month to $3,729/month after modest user growth."

Clerk is excellent—beautiful UI, easy setup, organizations work great. But at scale, the math breaks down.

Users Clerk (~$0.02/MAU) Supabase Auth Monthly Savings
10,000 ~$200 $0 (free tier) $200
25,000 ~$500 $25 $475
50,000 ~$1,000 $25 $975

This guide walks through migrating from Clerk to Supabase Auth while preserving your user data.


What We're Migrating

✅ Email/password users

✅ Social login users (Google, GitHub, etc.)

✅ User metadata and profiles

⚠️ Organizations (requires additional work)

❌ Active sessions (users will re-login once)


⚠️ Important: Active Sessions

All users will be logged out after migration. This is unavoidable.

Send an email to your users 24-48 hours before migration:

  • Explain you're upgrading your authentication system
  • Let them know they'll need to reset their password
  • Frame it as a security improvement

This is the biggest UX hurdle of the move. Don't skip the communication.


The Hard Part: Passwords

Let's address this upfront: Supabase Auth doesn't directly import bcrypt password hashes.

Clerk uses bcrypt. Supabase uses its own hashing. You have three options:

Option A: Force Password Reset (Simplest)

After migration, trigger password reset emails. Most users won't mind if you communicate well.

Option B: Use Better Auth

Better Auth supports bcrypt imports. Migrate Clerk → Better Auth → use with Supabase database.

Option C: Gradual Migration

Keep Clerk running, migrate users as they login (complex).

This guide uses Option A.


Step 1: Export Users from Clerk

Via Dashboard

  1. Go to Clerk Dashboard → Users
  2. Click Export Users
  3. Download the CSV

Via API (for larger datasets)

// export-clerk-users.ts
const CLERK_SECRET_KEY = process.env.CLERK_SECRET_KEY!;

async function exportAllUsers() {
  const users = [];
  let offset = 0;
  const limit = 500;

  while (true) {
    const response = await fetch(
      `https://api.clerk.com/v1/users?offset=${offset}&limit=${limit}`,
      {
        headers: { Authorization: `Bearer ${CLERK_SECRET_KEY}` },
      }
    );

    const batch = await response.json();
    if (batch.length === 0) break;

    users.push(...batch);
    offset += limit;
    console.log(`Fetched ${users.length} users...`);
  }

  await Bun.write('clerk-users.json', JSON.stringify(users, null, 2));
  console.log(`Exported ${users.length} users`);
}

exportAllUsers();
Enter fullscreen mode Exit fullscreen mode

Step 2: Set Up Supabase

Create Project

  1. Go to supabase.com → New Project
  2. Note your Project URL, Anon Key, and Service Role Key

Enable Auth Providers

Dashboard → Authentication → Providers:

  • Email (enabled by default)
  • Google (add OAuth credentials)
  • GitHub (add OAuth app)

Match your Clerk providers.

Create Profiles Table

-- Extended profiles table
create table public.profiles (
  id uuid references auth.users(id) on delete cascade primary key,
  clerk_id text unique,
  full_name text,
  avatar_url text,
  metadata jsonb default '{}',
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- Enable RLS
alter table public.profiles enable row level security;

-- Users can only access their own profile
create policy "Users can view own profile"
  on public.profiles for select
  using (auth.uid() = id);

create policy "Users can update own profile"
  on public.profiles for update
  using (auth.uid() = id);

-- Auto-create profile on signup
create or replace function public.handle_new_user()
returns trigger as $$
begin
  insert into public.profiles (id, full_name, avatar_url)
  values (
    new.id,
    new.raw_user_meta_data->>'full_name',
    new.raw_user_meta_data->>'avatar_url'
  );
  return new;
end;
$$ language plpgsql security definer;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute function public.handle_new_user();
Enter fullscreen mode Exit fullscreen mode

Step 3: Migration Script

// migrate-to-supabase.ts
import { createClient } from '@supabase/supabase-js';

const SUPABASE_URL = process.env.SUPABASE_URL!;
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_KEY!;

const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY, {
  auth: { autoRefreshToken: false, persistSession: false },
});

interface ClerkUser {
  id: string;
  first_name: string | null;
  last_name: string | null;
  email_addresses: Array<{ email_address: string; id: string }>;
  primary_email_address_id: string;
  image_url: string;
  created_at: number;
}

async function migrateUsers() {
  const clerkUsers: ClerkUser[] = JSON.parse(
    await Bun.file('clerk-users.json').text()
  );

  console.log(`Migrating ${clerkUsers.length} users...`);

  let success = 0;
  let failed = 0;

  for (const clerkUser of clerkUsers) {
    try {
      const primaryEmail = clerkUser.email_addresses.find(
        (e) => e.id === clerkUser.primary_email_address_id
      );

      if (!primaryEmail) throw new Error('No primary email');

      const { data, error } = await supabase.auth.admin.createUser({
        email: primaryEmail.email_address,
        email_confirm: true,
        user_metadata: {
          full_name: [clerkUser.first_name, clerkUser.last_name]
            .filter(Boolean)
            .join(' '),
          avatar_url: clerkUser.image_url,
          clerk_id: clerkUser.id,
        },
      });

      if (error) throw error;

      if (data.user) {
        await supabase.from('profiles').upsert({
          id: data.user.id,
          clerk_id: clerkUser.id,
          full_name: [clerkUser.first_name, clerkUser.last_name]
            .filter(Boolean)
            .join(' '),
          avatar_url: clerkUser.image_url,
        });
      }

      success++;
      console.log(`✓ ${primaryEmail.email_address}`);
    } catch (err: any) {
      failed++;
      console.error(`✗ ${clerkUser.id}: ${err.message}`);
    }

    await new Promise((r) => setTimeout(r, 50)); // Rate limit
  }

  console.log(`\nDone: ${success} success, ${failed} failed`);
}

migrateUsers();
Enter fullscreen mode Exit fullscreen mode

Step 4: Send Password Resets

// send-resets.ts
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_KEY!
);

async function sendPasswordResets() {
  const { data: users } = await supabase.auth.admin.listUsers();

  for (const user of users.users) {
    if (user.email) {
      await supabase.auth.resetPasswordForEmail(user.email, {
        redirectTo: 'https://yourapp.com/reset-password',
      });
      console.log(`Sent: ${user.email}`);
      await new Promise((r) => setTimeout(r, 100));
    }
  }
}

sendPasswordResets();
Enter fullscreen mode Exit fullscreen mode

Pro tip: Email users BEFORE migration explaining what's happening. Frame it as a security improvement.


Step 5: Update Your Frontend

Before (Clerk)

import { SignIn, useUser } from '@clerk/nextjs';

export function AuthButton() {
  const { user } = useUser();
  if (user) return <span>Hi, {user.firstName}</span>;
  return <SignIn />;
}
Enter fullscreen mode Exit fullscreen mode

After (Supabase)

'use client';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { Auth } from '@supabase/auth-ui-react';
import { ThemeSupa } from '@supabase/auth-ui-shared';

export function AuthButton() {
  const supabase = createClientComponentClient();

  return (
    <Auth
      supabaseClient={supabase}
      appearance={{ theme: ThemeSupa }}
      providers={['google', 'github']}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Update Middleware

Before (Clerk)

import { authMiddleware } from '@clerk/nextjs';

export default authMiddleware({
  publicRoutes: ['/'],
});
Enter fullscreen mode Exit fullscreen mode

After (Supabase)

import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export async function middleware(req: NextRequest) {
  const res = NextResponse.next();
  const supabase = createMiddlewareClient({ req, res });
  const { data: { session } } = await supabase.auth.getSession();

  if (!session && req.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', req.url));
  }

  return res;
}

export const config = {
  matcher: ['/dashboard/:path*'],
};
Enter fullscreen mode Exit fullscreen mode

Step 7: Update Database References

If your tables have Clerk user IDs:

-- Add Supabase user ID column
alter table your_table add column supabase_user_id uuid;

-- Map old IDs to new IDs via profiles
update your_table t
set supabase_user_id = p.id
from profiles p
where t.clerk_user_id = p.clerk_id;
Enter fullscreen mode Exit fullscreen mode

Migration Checklist

  • Export users from Clerk
  • Create Supabase project
  • Set up auth providers
  • Create profiles table
  • Run migration script
  • Send password reset emails
  • Update frontend components
  • Update middleware
  • Update database references
  • Test everything
  • Remove Clerk dependencies

Coming Soon: switch-kit CLI

I'm building a CLI to automate this entire process:

npx switch-kit migrate --from clerk --to supabase
Enter fullscreen mode Exit fullscreen mode

Planned features:

  • Automatic user export from Clerk API
  • Password hash migration (via Better Auth)
  • Organization structure mapping
  • Dry-run mode
  • Rollback support

→ Join the Waitlist


Questions?

Drop a comment below. Happy to help anyone going through this migration.


If this helped you, share it with other devs facing the Clerk pricing wall.

Top comments (0)