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
- Go to Clerk Dashboard → Users
- Click Export Users
- 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();
Step 2: Set Up Supabase
Create Project
- Go to supabase.com → New Project
- 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();
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();
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();
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 />;
}
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']}
/>
);
}
Step 6: Update Middleware
Before (Clerk)
import { authMiddleware } from '@clerk/nextjs';
export default authMiddleware({
publicRoutes: ['/'],
});
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*'],
};
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;
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
Planned features:
- Automatic user export from Clerk API
- Password hash migration (via Better Auth)
- Organization structure mapping
- Dry-run mode
- Rollback support
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)