Founder & Lead Engineer, RAITHub
To migrate from Bubble to code without downtime, don't rebuild everything and switch in one night. Export the data through Bubble's Data API into your own PostgreSQL database, rebuild one user journey at a time while Bubble keeps running the rest, sync changes between the two, and cut over each journey after a rehearsal, with Bubble kept read-only as your rollback.
Bubble has no code to export: its FAQ says there is "no traditional codebase", though it can provide a raw JSON export of your application logic to help a migration. So a Bubble migration is always a rebuild of the logic plus a move of the data. The rebuild is the part people plan for. The data move and the weeks when both systems run side by side are where migrations actually go wrong, so this guide spends most of its time there. If you are still deciding whether to leave, read no-code vs custom development first.
What do you need to list before migrating off Bubble?
Everything the app does, not just its pages. Bubble apps hide behaviour in places nobody remembers building. Before any code is written, make an inventory:
- Data types and fields, including option sets and which fields hold lists or files.
- Privacy rules, because they are your current permission model and the new system must reproduce them.
- Page workflows that change data, not only those that change what is shown.
- Backend workflows, scheduled and recurring, and any API workflows that outside services call.
- Plugins and API connector calls, with the accounts and keys they use.
- Emails, both what triggers them and what they say.
- Inbound webhooks, such as Stripe or Zapier calling your Workflow API endpoints.
The inventory becomes the test plan: each line is a behaviour the new system must match, or a behaviour you consciously drop.
How do you export data from Bubble with the Data API?
Two routes exist. The editor can export a data type as CSV, JSON or NDJSON from the app data section (Bubble export docs), which is fine for a one-off snapshot. For a migration you will run many times, use the Data API, because a script can be re-run, compared and scheduled.
The key facts from the Bubble Data API docs:
- You enable it under Settings, API, and tick each data type to expose.
- List endpoints look like
https://yourapp.bubbleapps.io/api/1.1/obj/typename, with/version-test/before/apifor the development database (Data API requests). - Pages are controlled by
cursorandlimit, and each response reports the cursor, the count returned and how many records remain. Results are sorted by creation date by default. - There is a 50,000-item limit on any GET request: in a type with 100,000 records, a cursor of 50,001 returns nothing. Enterprise plans raise it to 10,000,000.
- An admin API token, sent as
Authorization: Bearer, gives full database access and ignores privacy rules (Data API authentication). That is what an export needs, and exactly why the token must never leave your server.
The 50,000 limit is the trap. A naive loop that only advances the cursor silently stops at 50,000 records and reports success. The fix is to page inside a window, then start a new window from the last record's creation date, using a constraint. The script below does that and writes newline-delimited JSON:
// export-bubble.ts: export one Bubble data type past the 50,000-item cursor limit (Node 20+)
import { appendFile } from 'node:fs/promises'
const BASE = process.env.BUBBLE_API_BASE! // https://yourapp.bubbleapps.io/api/1.1/obj
const TOKEN = process.env.BUBBLE_API_TOKEN! // admin token: ignores privacy rules, keep it server-side
const TYPE = 'order'
const WINDOW = 45000 // stay safely under the 50,000 cursor ceiling
type Page = { response: { cursor: number; count: number; remaining: number; results: Record<string, unknown>[] } }
async function exportType(): Promise<void> {
let since = '1970-01-01T00:00:00.000Z'
let written = 0
for (;;) {
let cursor = 0
let remaining = 0
let last: string | undefined
do {
const url = new URL(BASE + '/' + TYPE)
url.searchParams.set('cursor', String(cursor))
url.searchParams.set('limit', '100')
url.searchParams.set('sort_field', 'Created Date')
url.searchParams.set('constraints', JSON.stringify([
{ key: 'Created Date', constraint_type: 'greater than', value: since },
]))
const res = await fetch(url, { headers: { Authorization: 'Bearer ' + TOKEN } })
if (!res.ok) throw new Error('Bubble returned ' + res.status)
const { response } = (await res.json()) as Page
if (response.count === 0) break
const lines = response.results.map((row) => JSON.stringify(row)).join('\n') + '\n'
await appendFile(TYPE + '.ndjson', lines)
written += response.count
cursor += response.count
remaining = response.remaining
last = response.results[response.results.length - 1]['Created Date'] as string
} while (remaining > 0 && cursor < WINDOW)
if (remaining === 0 || !last) break
// Overlap by 1 ms so records sharing the boundary timestamp are not skipped.
// The import upserts on the Bubble _id, so the overlap cannot create duplicates.
since = new Date(Date.parse(last) - 1).toISOString()
}
console.log('Wrote ' + written + ' rows for ' + TYPE)
}
exportType()
Run it against the development database first, compare record counts with what the Bubble editor shows, and remember that exports use server resources: Bubble measures these as workload units, which searches and bulk operations consume, so run large exports off-peak and watch the workload chart.
How do you load Bubble data into PostgreSQL?
Keep Bubble's unique ID on every row, and make every load an upsert keyed on it. Then the import can run a hundred times, during testing and during the sync period, and always converge on the same result:
-- Every migrated table keeps the Bubble id, so imports and syncs are repeatable.
create table orders (
id bigint generated always as identity primary key,
bubble_id text unique not null,
customer_id bigint references customers (id),
total_minor bigint not null,
currency char(3) not null,
created_at timestamptz not null,
modified_at timestamptz not null
);
insert into orders (bubble_id, customer_id, total_minor, currency, created_at, modified_at)
values ($1, $2, $3, $4, $5, $6)
on conflict (bubble_id) do update
set customer_id = excluded.customer_id,
total_minor = excluded.total_minor,
currency = excluded.currency,
modified_at = excluded.modified_at
where orders.modified_at < excluded.modified_at; -- never overwrite newer data
Load parent types first so references resolve, and translate Bubble's links between things into foreign keys by looking up the parent's bubble_id. Download stored files into your own storage rather than keeping links to files the old platform hosts, because those links end when the old account does.
Plan for passwords separately. We found no Bubble documentation describing an export of user password hashes, so plan as though you cannot move them: migrate accounts by email and ask users to set a new password or sign in with a magic link on first visit.
What is the strangler approach for a Bubble migration?
It means replacing the old system piece by piece while it keeps running, rather than all at once. Martin Fowler's Strangler Fig Application describes building new parts alongside the legacy system and gradually moving behaviour across, so that risk and return arrive in small, visible steps. For Bubble, the steps usually look like this:
- Put your domain in front. Users reach the product through a domain you control, so you can decide per journey which system serves it. The simplest split is by subdomain or path: the new code serves one journey, Bubble serves the rest.
- Pick the first journey. Choose one that is valuable but self-contained, such as reporting or the customer dashboard, not sign-up and payments.
- Give each kind of record one owner. For every data type, decide which system is the source of truth at each stage. Two systems both accepting edits to the same record is how migrations corrupt data.
- Sync in one direction. Re-run the export filtered on
Modified Dategreater than the last sync, every few minutes, so the new database follows Bubble. Where a migrated journey must write back, call Bubble's Data API or a Workflow API endpoint from the server; the Data API's create and modify rules are disabled by default on existing privacy rules, so enable them deliberately. - Move the next journey, and flip ownership of its data, until Bubble serves nothing.
| Stage | Bubble serves | New code serves | Source of truth |
|---|---|---|---|
| 1. Shadow | Everything | Nothing public; imports run nightly | Bubble |
| 2. Read-only journey | Everything else | Dashboards and reports | Bubble; new database synced every few minutes |
| 3. First write journey | Sign-up, payments, remaining workflows | One workflow that writes, such as orders | Split by data type, written down |
| 4. Core journeys | Legacy admin screens only | Sign-up, login, payments, core workflow | New database |
| 5. Retire | Read-only archive | Everything | New database |
How do you cut over without downtime?
Rehearse the cut-over until it is boring, then do it on a quiet day. For each stage that moves ownership of data:
- Rehearse on a copy. Run the full sequence against the development database and time it.
- Announce a short write freeze for the journey being moved, if needed: reads keep working, edits pause for minutes, not hours.
- Run a final delta sync of records modified since the last run.
- Verify: record counts per type, sums of money fields, and a sample of records checked by hand against Bubble.
- Switch the route for that journey to the new code, and repoint webhooks such as Stripe to the new endpoints.
- Keep Bubble read-only for a month. It is your rollback and your reference. Do not delete anything until the new system has run a full billing cycle.
Payment webhooks deserve their own check. During the switch, events can arrive at either system, so make the new handler process each event ID exactly once and reconcile against the provider's dashboard after the cut-over.
How long does a Bubble migration take?
It depends on the inventory, not on the number of pages. As engineering guidance rather than a RAITHub benchmark: an app with a handful of data types, one core journey and few integrations is a matter of weeks; an app with many roles, scheduled workflows and payment flows takes months, mostly in the sync and verification stages. For a sense of build timelines by scope, see how long an MVP takes to build, and for the rebuild-or-refactor question in general, rewrite vs refactor.
Why RAITHub for a Bubble migration?
- Data first, with tests. Repeatable imports, count and total checks, and a test suite on the new system from the first journey. PropDesk runs 1,024 tests; Sundor Skin 530+ across 146 PostgreSQL tables with row-level security.
- Payments done carefully. PropDesk collects rent through Stripe, and TheSkinProof, the founder's own marketplace venture rather than a client project, handles bKash, Nagad, SSLCommerz and cash on delivery.
- A fixed quote per stage. A free 15-minute technical audit, then a fixed written quote. See code rescue for inherited systems, or the MVP development service for the rebuild.
RAITHub has not published a Bubble migration case study, so treat the plan above as engineering guidance.
When to stay on Bubble instead
- Your only problem is performance on a few pages. Optimising searches and workflows inside Bubble is far cheaper than a migration.
- Nobody will maintain custom code. Without a budget for updates and fixes, the platform is the safer home.
- Demand is not proven yet. Migrate a product people pay for, not one you are still testing.
Bubble documentation checked on 29 September 2026.
If you are planning a move off Bubble, book the free 15-minute technical audit and bring your inventory, even a rough one.
Frequently asked questions
Can I export my Bubble app as code?
No. Bubble says there is no traditional codebase to export. It can provide a raw JSON export of your application logic to help a migration, and your data can be exported as CSV, JSON or NDJSON, or through the Data API.
Why does my Bubble Data API export stop at 50,000 records?
Bubble limits any GET request to 50,000 items, so a cursor past 50,000 returns nothing, except on Enterprise, where the limit is 10,000,000. Page within windows and start each new window from the last record's creation date using a constraint.
Can I migrate user passwords from Bubble?
Plan as though you cannot. We found no Bubble documentation describing a password-hash export. Migrate accounts by email and have users set a new password or use a magic link on first sign-in.
Do I have to rebuild everything before switching?
No. With a strangler approach, the new code takes over one journey at a time while Bubble serves the rest, with one system owning each kind of record at every stage.
How do I avoid downtime during the cut-over?
Rehearse on a copy, use a short write freeze only for the journey being moved, run a final delta sync, verify counts and totals, switch the route and webhooks, and keep Bubble read-only for a month as a rollback.
Is the Bubble Data API token safe to use in a script?
Only on a server you control. An admin token ignores privacy rules and gives full database access, so it must never reach a browser, a public repository or a front-end build.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.