How I migrated Tabhive from Supabase to PlanetScale + better-auth

Backend

In this post I'll walk through how I migrated Tabhive from Supabase to PlanetScale + better-auth. If you're looking to do something similar, this might be useful for you.

Why Migrate

I was using Supabase for two things in Tabhive: the database and authentication.

Performance

Performance was not great, especially with RLS enabled. RLS adds overhead to every query and it was noticeable.

Vendor Lock-in

I had used the Supabase JS client and that made the migration way harder than it needed to be. I spent quite a bit of time stripping out that code.

With PlanetScale, the app connects to the database using a connection string. If I want to move to a different provider, I just update the connection string and migrate the data. That's it.

This is not unique to PlanetScale. You could connect to Supabase DB using the connection string too.

No Native Transaction Support

Supabase doesn't support database transactions directly. To use transactions, you have to write a Postgres function and call it via .rpc() from the Supabase JS client. These database functions are hard to maintain and manage over time. With Drizzle, I can write transactions in code like any other query.

The other option is to connect to Supabase DB using the connection string and use transactions from there.

Authentication

I wanted to move away from Supabase Auth. better-auth is open source, free, and honestly much better. It gives me more control and I'm not locked in to a single provider.

Pricing

Supabase's paid plan starts at $25/month. In my case, I was paying for a lot of things I wasn't using.

PlanetScale starts at $5/month and for that you get:

  • 10GB storage
  • 20GB backup storage
  • Daily backups
  • Point-in-time recovery

Point-in-time recovery on Supabase costs an extra $100/month. On PlanetScale it's included in the $5 plan.

Pricing is the biggest reason why I decided to migrate away.

Scale

I'm planning to do a lifetime deal campaign and potentially launch on AppSumo. If that goes well, I need to be ready to scale.

With Supabase, scaling gets expensive fast. There are charges for monthly active users, file storage, egress, and more.

With PlanetScale, I just upgrade the database instance when I need more capacity. And since better-auth is open source, there's no cost for auth no matter how many users I have.

Also, since I'm no longer tied to Supabase, I'm not locked into any specific platform. If I ever want to move to a different database provider, it's just a connection string update and a data migration. Much simpler than what I had before.

Preparing for the Migration

Before doing the actual migration, I had to prepare the codebase.

Step 1: Stop Using the Supabase JS Client for Database Access

The Supabase JS client ties your code to Supabase. If everything goes through that client, switching providers is very difficult.

So I switched to connecting to the database directly using the connection string. I introduced Drizzle ORM for database access and rewrote all the Supabase client calls to use Drizzle.

Step 2: Move Database Functions to Code

I had a few Postgres functions that handled transactions. I rewrote all of them as Drizzle transactions in code. Much easier to read, write, and maintain.

Step 3: Move All Database Calls to the Server

Supabase lets you query the database from the client using RLS policies. That works fine on Supabase but doesn't translate to other providers.

So I moved all data fetching to the server. The Chrome extension calls an API on the server, the server queries the database and returns the data. No more direct database access from the client.

This also put my security concerns to rest. It's easy to mess up RLS policies and expose yourself to a security vulnerability.

Test Run

Before touching production, I did a full dry run locally.

Export and Import Data

I exported all the data from Supabase and imported it into a fresh PlanetScale database using the pg_dump and restore guide from PlanetScale. This gave me a real environment to test against.

Migrate Auth to better-auth Locally

I updated the local environment to point to PlanetScale and swapped Supabase Auth for better-auth. better-auth has a migration guide for migrating from Supabase. It was very smooth. Even social login worked without me doing anything beyond what's in the guide.

The one thing I had to fix manually was the foreign keys. Supabase uses UUIDs for user IDs and better-auth uses text. So every table with a foreign key pointing to auth.users had to be updated to use the data type text instead.

Once that was done, I tested all the flows: login, signup, password reset, social login, and the core app. Everything worked.

One thing to note: all existing user sessions are invalidated after this migration. So after the release, everyone has to re-login once.

The Migration

With the test run done, the actual migration was straightforward:

  1. Publish a new version of the extension that uses better-auth and fetches data from the server
  2. Put the app in maintenance mode
  3. Manually back up data from Supabase using these commands from the Supabase backup guide:
supabase db dump --db-url [CONNECTION_STRING] -f roles.sql --role-only
supabase db dump --db-url [CONNECTION_STRING] -f schema.sql
supabase db dump --db-url [CONNECTION_STRING] -f data.sql --use-copy --data-only
  1. Export the latest data from Supabase
  2. Import it into PlanetScale
  3. Update the data type of the user foreign key
  4. Deploy the updated code
  5. Verify everything and bring the app back online
    1. Verify username/password login
    2. Verify social login
    3. Verify new signups
  6. Update changelog

After importing the data into PlanetScale, I verified that everything came over correctly. I ran row count checks across all the tables and compared them against Supabase. I also checked sequence numbers to make sure nothing was missing or out of order. Only after that did I deploy and bring the app back online.

I also kept Supabase running for a while after the migration. This was my rollback plan — if something went wrong, I could switch back. It also meant I had a way to recover any data that didn't import correctly, just in case.

Conclusion

The migration went well overall. The prep work I did before the actual migration day made a big difference.

better-auth was a pleasant surprise. I expected the auth migration to be the hardest part but it was actually the smoothest. The migration guide covers everything and social login just worked.

For my app, it was fine to have a little downtime. If you don't want any downtime, you can look at WAL streaming for importing data.

7cd8770b-ee9c-4267-9ecb-28cdcd8eb7f0