Migrating from No-Code to Production Code: Database Extraction, API Reconstruction, and Incremental Cutover Strategies
A technical guide for teams outgrowing Bubble, Retool, or Webflow. Covers data extraction, schema redesign, rebuilding typed APIs, auth migration, and incremental cutover strategies that avoid the big bang rewrite trap.
You built something real with Bubble, Retool, or Webflow. Users are paying, workflows are running, and the product works. Then one day you try to add a feature that the platform should handle easily, and you spend three days working around constraints you did not expect to exist. That is the ceiling.
The ceiling is not a bug in the platform. These tools are designed for a specific range of problems: fast prototype, internal tooling, simple CRUD apps, content sites. When your product grows beyond that range, you are not fighting incompetent software. You are fighting a mismatch between what the tool was built for and what you now need.
This guide covers how to migrate off a no-code platform without losing data, breaking workflows, or gambling on a rewrite that takes six months and ships broken.
When to Migrate: The Decision Framework
The wrong time to migrate is when you are frustrated. Frustration is not a signal. These are signals:
Hard limits you cannot engineer around:
- Bubble’s database response times degrade nonlinearly past ~50K records per type, and query composition options are limited
- Retool cannot run background jobs reliably; it is a frontend runtime with database access bolted on
- Webflow’s CMS API rate limits (60 requests/min on Growth) block you from building integrations that need to sync at volume
- Custom authentication logic (SAML, multi-tenant JWT, device fingerprinting) is either impossible or requires hacks that will break on platform updates
Cost signals:
- You are paying $500-$2,000/month in platform fees for capabilities you could run on $50/month in infrastructure
- You are paying engineers to work around platform constraints instead of building product
Velocity signals:
- A feature that would take two days in custom code takes two weeks in the platform because of missing primitives
- You cannot test changes without deploying to production because the platform has no local dev environment
- You have hired engineers who are blocked by the platform, not by their own skill
If you hit two or more of these, the migration math is likely positive. If you hit one, re-evaluate in 90 days.
| Signal | Bubble | Retool | Webflow |
|---|---|---|---|
| Database scale ceiling | ~50K records/type; no indexing control | N/A (uses your DB) | N/A (headless via API) |
| Custom auth | Partial (JWT plugin, fragile) | Limited (SSO add-on) | No native auth |
| Background jobs | Scheduled workflows only; no queues | Not designed for it | Not designed for it |
| Custom business logic | Workflows; no branching on server | JavaScript actions; limited | No logic layer |
| Local development | None | Partial (self-hosted Retool) | None |
| Typical exit trigger | Scale + auth complexity | Reliability + no async | Dynamic content + auth |
The Migration Trap to Avoid
The most common failure mode is the parallel build: stop all feature work, build the custom stack, migrate everything, then cut over. Teams call this “the rewrite.” It typically takes 3x as long as estimated, ships incomplete, and creates a six-month window where the original platform is frozen and the new stack is not production-ready.
The alternative is an incremental cutover. You run both systems simultaneously, move pieces of the application one at a time, and use feature flags or traffic splitting to control what percentage of users touch the new stack at each step. This adds complexity in the short term but eliminates the all-or-nothing risk.
Step 1: Data Extraction and Schema Redesign
Bubble stores data in a document-oriented structure that maps to its visual data types. When you export Bubble data, you get CSVs with columns like _id, Created Date, Modified Date, and whatever fields you defined, but with foreign keys stored as Bubble internal IDs, not the record values.
Start by exporting all data types from Bubble’s App Data panel. For anything beyond 10,000 records, use the Data API in batches:
interface BubbleRecord {
_id: string;
"Created Date": string;
"Modified Date": string;
[key: string]: unknown;
}
interface BubbleApiResponse<T> {
response: {
results: T[];
count: number;
remaining: number;
cursor: number;
};
}
async function extractBubbleType<T extends BubbleRecord>(
appName: string,
apiToken: string,
typeName: string,
batchSize = 100
): Promise<T[]> {
const records: T[] = [];
let cursor = 0;
let remaining = 1;
while (remaining > 0) {
const url = new URL(
`https://${appName}.bubbleapps.io/api/1.1/obj/${typeName}`
);
url.searchParams.set("cursor", String(cursor));
url.searchParams.set("limit", String(batchSize));
const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${apiToken}` },
});
if (!res.ok) {
throw new Error(`Bubble API error ${res.status}: ${await res.text()}`);
}
const data: BubbleApiResponse<T> = await res.json();
records.push(...data.response.results);
remaining = data.response.remaining;
cursor += batchSize;
// Bubble rate limits to 50 requests/second on paid plans
await new Promise((r) => setTimeout(r, 25));
}
return records;
}
The harder problem is schema redesign. Bubble’s document store flattens relationships that should be normalized. A typical Bubble app has:
- Lists stored as repeated fields on a record (e.g.,
tagsas a comma-separated string or a Bubble list type) - Many-to-many relationships modeled as lists on one side only
- Computed fields that mix stored values with derived logic
- File attachments stored as Bubble CDN URLs that will break after migration
For Postgres, you want proper normalization. A Bubble “User” type with a list of “Orders” becomes two tables with a foreign key, not a denormalized list field on the user row.
// Mapping Bubble export structure to Postgres insert
interface BubbleUser {
_id: string;
"Created Date": string;
email: string;
name: string;
// Bubble stores related IDs as arrays
"Order List": string[];
}
interface PostgresUser {
id: string; // map from Bubble _id
created_at: Date;
email: string;
name: string;
// Orders become a separate table; no array column
}
function mapBubbleUser(raw: BubbleUser): PostgresUser {
return {
id: raw._id,
created_at: new Date(raw["Created Date"]),
email: raw.email,
name: raw.name,
};
}
// Orders table gets a user_id FK; this is built from the Order List on the user
function extractOrderForeignKeys(
users: BubbleUser[]
): Array<{ order_id: string; user_id: string }> {
return users.flatMap((user) =>
user["Order List"].map((orderId) => ({
order_id: orderId,
user_id: user._id,
}))
);
}
Before you write a single migration script, draw the target schema in SQL. Run EXPLAIN ANALYZE on the queries your application will actually run. Bubble made certain query patterns easy by hiding their cost. Postgres will surface that cost honestly.
Step 2: Rebuilding Business Logic as Typed APIs
Bubble workflows and Retool queries are visual representations of business logic. They need to become code. The discipline here is: do not migrate the implementation, migrate the behavior.
For each Bubble workflow or Retool query, write down what it does in plain language, then implement it in TypeScript. Do not try to translate the visual flow step-by-step. That produces unmaintainable code that mirrors the platform’s structure rather than your domain’s structure.
Hono is a good choice for this layer because it is lightweight, works on Cloudflare Workers and Node.js without changes, and has first-class TypeScript support:
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { db } from "../db";
const app = new Hono();
const createOrderSchema = z.object({
userId: z.string().uuid(),
items: z.array(
z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
})
),
shippingAddressId: z.string().uuid(),
});
// This replaces a Bubble workflow that had 8 action steps
// The logic is identical; the structure is now testable
app.post("/orders", zValidator("json", createOrderSchema), async (c) => {
const body = c.req.valid("json");
// Validate stock before inserting
const stockCheck = await db.query.products.findMany({
where: (products, { inArray }) =>
inArray(
products.id,
body.items.map((i) => i.productId)
),
columns: { id: true, stockQuantity: true },
});
const outOfStock = stockCheck.filter((product) => {
const requested = body.items.find((i) => i.productId === product.id);
return requested && product.stockQuantity < requested.quantity;
});
if (outOfStock.length > 0) {
return c.json(
{ error: "insufficient_stock", productIds: outOfStock.map((p) => p.id) },
422
);
}
const order = await db.transaction(async (tx) => {
const [newOrder] = await tx
.insert(orders)
.values({
userId: body.userId,
shippingAddressId: body.shippingAddressId,
status: "pending",
})
.returning();
await tx.insert(orderItems).values(
body.items.map((item) => ({
orderId: newOrder.id,
productId: item.productId,
quantity: item.quantity,
}))
);
return newOrder;
});
return c.json({ orderId: order.id }, 201);
});
export default app;
One pattern worth adopting from the start: keep your business logic in pure functions that take typed inputs and return typed outputs, separated from the HTTP handler. The handler validates, calls the function, and formats the response. The function can be unit tested without spinning up an HTTP server.
Step 3: Authentication Migration
Authentication is the most dangerous piece to migrate. If you get it wrong, users are locked out.
The safest strategy is to keep the existing auth system alive during the transition and issue new tokens in parallel. For Bubble apps, users authenticate against Bubble’s auth, and Bubble returns a session token. You cannot take over those sessions, but you can create a shadow auth system that users opt into.
The pattern:
- Add a “link account” flow in the Bubble app that creates a corresponding user record in your Postgres database
- Issue a JWT from your new API when the user links their account
- Store the JWT in the client (localStorage or an httpOnly cookie)
- As pages migrate to the new stack, they use the JWT. Pages still on Bubble use the Bubble session
import { sign, verify } from "hono/jwt";
interface TokenPayload {
sub: string; // user ID in your new system
email: string;
iat: number;
exp: number;
}
const JWT_SECRET = process.env.JWT_SECRET!;
const TOKEN_TTL = 60 * 60 * 24 * 7; // 7 days
export async function issueToken(userId: string, email: string): Promise<string> {
const now = Math.floor(Date.now() / 1000);
return sign(
{
sub: userId,
email,
iat: now,
exp: now + TOKEN_TTL,
} satisfies TokenPayload,
JWT_SECRET
);
}
export async function verifyToken(token: string): Promise<TokenPayload> {
const payload = await verify(token, JWT_SECRET);
return payload as TokenPayload;
}
// Auth middleware for Hono routes
export const requireAuth = async (c: Context, next: Next) => {
const authHeader = c.req.header("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return c.json({ error: "unauthorized" }, 401);
}
try {
const payload = await verifyToken(authHeader.slice(7));
c.set("userId", payload.sub);
await next();
} catch {
return c.json({ error: "invalid_token" }, 401);
}
};
For apps with external identity providers (Google, GitHub), you can migrate users to your own OAuth flow without touching their passwords. For apps using Bubble’s native email/password auth, you need to either force a password reset on first login to the new system, or implement a credential migration endpoint that accepts the current Bubble token, validates it against Bubble’s API, and returns your JWT.
Step 4: Incremental Cutover with Feature Flags
The goal is to move one slice of the application at a time. Each slice gets a feature flag. When the flag is off, traffic goes to the old platform. When it is on, traffic goes to the new API.
You do not need a feature flag platform for this. A simple database-backed flag with a percentage rollout is enough:
interface FeatureFlag {
name: string;
enabled: boolean;
rolloutPercent: number; // 0-100
}
// Deterministic rollout: same user always gets same result
function isInRollout(userId: string, flagName: string, percent: number): boolean {
if (percent === 0) return false;
if (percent === 100) return true;
// Hash the userId + flagName to get a stable 0-99 bucket
let hash = 0;
const key = `${userId}:${flagName}`;
for (let i = 0; i < key.length; i++) {
hash = (hash * 31 + key.charCodeAt(i)) & 0xffffffff;
}
const bucket = Math.abs(hash) % 100;
return bucket < percent;
}
export async function getFlag(
flagName: string,
userId: string
): Promise<boolean> {
const flag = await db.query.featureFlags.findFirst({
where: (flags, { eq }) => eq(flags.name, flagName),
});
if (!flag || !flag.enabled) return false;
return isInRollout(userId, flagName, flag.rolloutPercent);
}
On the client side, you check the flag before deciding where to send a request:
async function submitOrder(orderData: OrderFormData): Promise<OrderResult> {
const useNewApi = await getFlag("new-order-api", currentUserId);
if (useNewApi) {
const res = await fetch("/api/orders", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${getToken()}`,
},
body: JSON.stringify(orderData),
});
return res.json();
}
// Original Bubble workflow call
return callBubbleWorkflow("create-order", orderData);
}
Roll out at 5%, monitor error rates and latency for 24-48 hours, then move to 25%, 50%, 100%. If error rates spike above baseline, flip the flag back to 0% immediately. Fix the issue before continuing.
Production Considerations
Data consistency during parallel operation. While both systems run simultaneously, writes can happen to both. You need to decide which is the source of truth. The simplest rule: all writes go to the new stack once the flag is above 0% for a given feature. The old platform reads data forwarded from the new stack, or reads its own stale copy until fully cut over.
Bubble file attachments. Files stored in Bubble’s CDN are hosted at appname.bubbleapps.io. After you terminate the Bubble subscription, those URLs break. Run a migration job that fetches each attachment and copies it to your own S3 or R2 bucket before you cancel the subscription. Map old URLs to new URLs in a lookup table; replace references in Postgres data before going live.
Rollback points. Every cutover step should have a defined rollback. “We can flip the feature flag back” is not a complete rollback plan. Data written to the new system after the flag went live needs to be either replayed to the old system or accepted as lost during a rollback. Know which situation applies to each feature before you flip the flag.
Testing parity. Before cutting over any feature, run both implementations against the same inputs and compare outputs. This is not unit testing; it is shadow mode validation. Log the responses from both the old platform call and the new API call, and diff them. Discrepancies surface logic bugs you would otherwise only discover post-cutover.
Migration Strategy Tradeoffs
| Approach | Risk | Speed | Rollback ease | When to use |
|---|---|---|---|---|
| Big bang rewrite | High: ships incomplete or broken | Feels fast; usually slow | None after cutover | Almost never |
| Strangler fig (feature by feature) | Low: isolated surface area | Slower per feature, no all-or-nothing risk | Full: flag per feature | Standard; use this |
| Read-only migration first | Low: no write path changes | Moderate | Easy | High-read, low-write apps |
| Dual-write with sync | Medium: consistency complexity | Moderate | Moderate | When Bubble is still authoritative for some users |
| Lift-and-shift schema | Medium: misses schema improvements | Fast extraction | Easy to revert | Rarely; you will regret not normalizing |
Closing
The no-code ceiling is a real engineering constraint, not a sign that the platform failed you. Bubble got a product to market. Retool got an internal tool shipped. Webflow got a site live. The migration is the second build, and the second build is almost always harder because you are running it while the first build is serving real users.
The migration traps worth avoiding: rewriting everything in parallel, migrating the schema without normalizing it, cutting over auth all at once, and treating feature flags as optional complexity. None of these are hard to get right. They are just easy to skip when you are in a hurry to get off a platform that is frustrating you.
Do the data extraction carefully. Normalize the schema before you import. Build the feature flags before you need them. Cut over one slice at a time. The exit takes longer than the initial build. That is normal.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.