On this page 7 sections
Here’s how to migrate Bubble app to Supabase without losing data or users: export each data type in Created Date windows to stay under the Data API’s 50,000-item cap, rebuild it as Postgres tables, rewrite privacy rules as row level security, then move each user on their next login. Done in that order, nobody gets a password reset email.
Most guides stop at the data. The data is the easy part. The parts that hurt are the users (Bubble won’t hand over password hashes), the privacy rules (they don’t translate one to one) and the files, which still live on Bubble’s storage after you’ve moved everything else. If you’re still deciding whether to leave at all, start with the four signs a Bubble app has outgrown the platform. This piece assumes you’ve decided.
How to migrate Bubble app to Supabase, in order
The order matters more than any single step. Each one depends on the one before it.
- Inventory. List every data type, field, option set, privacy rule, file field and workflow, including backend and scheduled workflows. This list is your test plan for cutover.
- Schema. Build the Postgres tables in Supabase, with a
bubble_idcolumn on every table. - Data. Export from Bubble, load into Supabase, and rehearse it until the row counts match.
- Rules. Rewrite each privacy rule as a row level security policy before any client touches the new database.
- Users. Pre-create every account in Supabase Auth, then move passwords on first login.
- Files and cutover. Copy files to Supabase Storage, run a final delta sync, and switch traffic.
Supabase gives you Postgres, auth, storage and edge functions. It doesn’t give you Bubble’s visual workflow editor, so the workflows in your inventory become database functions, edge functions or code in your new frontend. That’s the largest piece of work in any Bubble exit, and it’s separate from this guide.
Export every data type past the 50,000 cap
Bubble gives you two ways out. The first is the editor: under Data, App data, you pick a data type, set up a view, click Export and choose CSV, JSON or NDJSON. Bubble’s export docs say it emails you a download link when the file is ready, and that the export shares a queue with your scheduled API workflows, so it can wait its turn on a busy app. One known issue to plan for: date interval fields export as text like “2 days” even though Bubble stores them as milliseconds. Convert those before loading.
The second way is the Data API, and it’s the one to script, because you’ll run the export more than once. You enable it under Settings, API, and choose which data types to expose. The catch is in Bubble’s Data API request docs: there is a limit of 50,000 items in any GET request, so with 100,000 records and the cursor at 50,001, you get nothing back. Enterprise plans raise that to 10,000,000.
The fix is to never ask for more than 50,000 at once. Filter each request with a constraint on Created Date, export one month (or one week, for busy tables) at a time, page through each window with the cursor, and write each window to its own NDJSON file. Turn on exclude_remaining for large tables so Bubble doesn’t count every remaining record on each call.
Keep three fields from every record:
_idgoes into abubble_idtext column with a unique index. Every reference between records in the export points at these IDs, so you’ll need them to rebuild foreign keys.- Created Date and Modified Date go into
created_atandmodified_at. Bubble sets both itself and won’t let API clients override them, which makes Modified Date a reliable cursor for the delta sync at cutover.
Map things, lists and option sets to Postgres
| Bubble | Supabase / Postgres |
|---|---|
| Data type | Table, uuid primary key plus bubble_id |
| Field pointing at another thing | Foreign key column, filled in a second pass from bubble_id |
| List of things | Join table with two foreign keys |
| List of text or numbers | Array column, or a child table if you’ll filter on it |
| Option set | Enum, or a lookup table |
| User data type | auth.users plus a profiles table |
Option sets need a decision of their own. Bubble’s option set docs say they’re part of your app’s source code, stored as JSON in a file called dynamic.js that ships with the app. Your export carries the option value on each record and nothing about the set itself. Rebuild short, stable lists as enums. Rebuild anything with extra attributes (a label, a color, a sort order) as a lookup table.
To load the files, Supabase’s import guide lists the dashboard CSV importer, COPY through psql, and pgloader. The dashboard importer has a 100MB size limit, so anything bigger goes through COPY. The same guide recommends disabling triggers during the import and building indexes afterwards. Load all tables first, then run a second pass that turns bubble_id references into foreign keys.
Rewrite privacy rules as row level security
Bubble’s privacy rules and Supabase’s row level security do the same job with opposite defaults, and that’s where migrations leak data.
In Bubble, the privacy editor marks each data type as either “Privacy rules applied” or “Publicly visible”, per Bubble’s privacy rule docs. Each rule grants permissions like finding records in searches, viewing files attached to a record, and viewing individual fields.
In Supabase, the row level security guide is blunt: a table in an exposed schema without RLS is readable and writable by any role with a grant on it. Once RLS is on, nothing is readable through the API with the publishable key until you write policies. So turn RLS on for every table before you load data, then add policies one rule at a time:
alter table public.orders enable row level security;
create policy "Owners can read their orders"
on public.orders for select
to authenticated
using ( (select auth.uid()) = owner_id );
Two traps. Bubble’s field-level permissions (hide the email field from everyone but the owner) have no direct RLS equivalent, because RLS filters rows, not columns. Move those fields into a separate table with a stricter policy, or serve them through a view or a server-side function. And owner_id has to hold the Supabase user’s UUID, which is why users are created before you write ownership columns (next section). When a policy doesn’t behave, our guide to why Supabase row level security fails covers the six causes we see most.
Move users on their next login
This is the step that decides whether your launch day is quiet. Bubble stores passwords separately and encrypted, so they’re not usable by another system, as a long-running Bubble forum thread explains. The common answer is to import emails and send everyone a reset link. Every user who ignores that email is locked out until they find it.
There’s a better route, built from two documented features:
- Bubble can still check a password. An API workflow in your Bubble app that runs the Log the User in action takes an email and password, and Bubble responds with a token if it succeeds.
- Supabase accepts a password you already have. Supabase’s Auth0 migration guide shows
auth.admin.createUsertaking a bcrypt or Argon2password_hash, or a plaintext password it hashes for you, withemail_confirm: truefor confirmed users. Andauth.admin.updateUserByIdcan set a password on an existing user.
Put together:
- Before cutover, create every Bubble user in Supabase Auth with their email,
email_confirm: true, no password, and theirbubble_idinapp_metadata. Now every user has a Supabase UUID, so you can fill everyowner_idcolumn during the data load. - On login in the new app, try Supabase first. If that fails and the user is still flagged as unmigrated, your server sends the email and password to the Bubble login workflow. If Bubble returns a token, call
updateUserByIdwith that password, clear the flag and sign the user in. The user typed their usual password and never noticed. - After a few weeks, send a normal reset email to the users who haven’t logged in, then switch off the Bubble login workflow.
Run the Bubble check on your server only, over HTTPS, rate-limited, and never log the password. Users who signed up with Google or another OAuth provider don’t need any of this; they sign in with the same provider on Supabase.
Copy files before you cancel the plan
File fields in your export are URLs pointing at Bubble’s storage. Download each file, upload it to a Supabase Storage bucket, and rewrite the URL column to the new path. Supabase’s upload docs recommend standard uploads for files up to 6MB and resumable TUS uploads above that. Run the file copy early, check the counts, and keep your Bubble plan active until every URL in the database points at Supabase.
Cut over with a delta sync
Rehearse the full export and import at least twice, on a staging Supabase project, comparing row counts per table. Then on cutover day, run only the delta: every record whose Modified Date is newer than your last full export. Freeze writes on the Bubble side for the minutes that takes, switch traffic, and keep Bubble read-only for a few weeks as your fallback. Keep the URL structure identical too; the Bubble rebuild guide covers why changed URLs cost you rankings.
If you’re moving the app one piece at a time, the same scripts become a one-way sync: Bubble stays the source of truth, Supabase is read-only for the new code, and write-back comes later.
Which migration path fits your app
| Your app | Path |
|---|---|
| Under 50,000 records per type, few files | Editor CSV export, dashboard import, one weekend cutover |
| Any type over 50,000 records | Scripted Data API export in Created Date windows, COPY import |
| Users who would churn on a reset email | Pre-create users, migrate passwords on login |
| Mostly Google or OAuth sign-in | Import users, skip the password step |
| Heavy field-level privacy rules | Split sensitive fields into their own tables before loading |
| Frontend still on Bubble for months | One-way sync, move one feature at a time |
If you’re not sure which row you’re in, an hour with your Bubble data tab and privacy editor will tell you. Count records per type, count file fields, count privacy rules with field-level permissions.
What building Glibzter on Bubble in 6 weeks taught us
We built Glibzter on Bubble in 6 weeks: scored assessments, a video course player, 1:1 coach booking and Stripe checkout. It runs two product surfaces on one auth system, one wallet and one content backbone, all in a single Bubble app. That design is what makes a Bubble app feel like one product, and it’s also why exits get hard: one User data type, referenced by assessments, bookings, payments and course progress, with privacy rules on each.
Building that taught us where the export has to be careful. Every reference in the export points at a _id, so the user table has to be mapped first or nothing joins. Option sets live outside the database, so a migration that only reads the export misses the lists your app runs on. And the privacy rules on a shared User type are the ones most likely to leak when translated in a hurry. We plan every Bubble to code migration around those three points.
That work runs as our Code Rescue engagement: a 2-4 week block, starting at $6,000, that moves one piece of a Bubble app, usually the backend and data, into code while the rest keeps running. If you’d rather know what’s hurting before you move anything, a Performance or Security audit starts at $1,000 and takes 1-2 weeks.
Frequently asked questions
- Can I export user passwords from Bubble?
- No. Bubble stores passwords separately and encrypted, so its exports carry no hash another system can reuse. You can still avoid a mass reset: pre-create every account in Supabase, then on each user's first login check the password against Bubble's Log the User in action and, if it passes, set that same password in Supabase through the admin API.
- How do I export more than 50,000 records from Bubble?
- Split the export into date windows. Bubble's Data API stops returning results past 50,000 items in any GET request on non-Enterprise plans, so filter each request by Created Date, page through each window with the cursor, and write the results to NDJSON. For one-off exports, the App data tab can also export a data type to CSV, JSON or NDJSON.
- What happens to Bubble option sets in Supabase?
- They become Postgres enums or small lookup tables. Option sets live in your Bubble app's source code instead of its database, so a data export only gives you the option values stored on each record. Use an enum when the list rarely changes, and a lookup table when it carries extra attributes like a label, color or sort order.
- How long does a Bubble to Supabase migration take?
- For the data layer alone, plan on a few weeks for a mid-sized app: schema and export scripts first, then row level security and user migration, then a rehearsal run and the cutover. Rebuilding the frontend and workflows is the larger job, which is why we move a Bubble app in 2-4 week blocks instead of one big rewrite.
- Do I need to rebuild my Bubble workflows in Supabase?
- Yes. Supabase gives you Postgres, auth, storage and edge functions, but it has no visual workflow editor, so each Bubble workflow becomes database logic, an edge function or code in your new app. Inventory every workflow before the move, including backend and scheduled ones, because the forgotten ones are the ones that break after cutover.
References
- Bubble - Exporting data
- Bubble - Data API requests
- Bubble - The Data API
- Bubble - Option sets
- Bubble - Protecting data with privacy rules
- Bubble - Authenticating with the Bubble API as a user
- Bubble forum - Exporting user data
- Supabase - Import data into Supabase
- Supabase - Row Level Security
- Supabase - Migrate from Auth0 to Supabase Auth
- Supabase - auth.admin.updateUserById
- Supabase - Standard uploads
Prashant Abbi