Use case · Bubble to production

From Bubble to a real codebase, without losing the users

We rewrite the Bubble app on Next.js, Supabase and TypeScript strict. Workflows become typed Server Actions, the data model becomes Postgres with row-level security, the users come along with a hashed-password reset on the cutover day.

The problem

Bubble was right until it was not

Bubble works for a first MVP. The visual builder ships a working product, the no-code logic survives the first round of feedback, and the founder gets to test the idea without hiring engineers. The problem starts when the product works. Performance does not scale, the business logic becomes unreadable inside the visual workflow editor, vendor lock-in tightens, and every engineer the founder talks to walks away when they hear "it is built in Bubble". The MVP that proved the idea is the same thing now blocking the company from growing past it.

Our approach

The six steps of a Bubble migration

Each step has a deliverable the team can see. The cutover happens on a single day with a documented rollback plan, not on a Friday afternoon with a prayer.

  1. 01

    Audit the Bubble app

    We map the data model (Bubble tables to Postgres tables), the workflows (each one becomes a Server Action), the auth setup, the third-party integrations and the live user base. The audit produces a written scope and a number before any code gets written.

  2. 02

    Translate the data model to Postgres

    Every Bubble table becomes a Postgres table with proper foreign keys, indexes and row-level security policies. Implicit relationships in Bubble become explicit constraints in SQL. The schema becomes something an engineer can read.

  3. 03

    Port the business logic to typed Server Actions

    Every Bubble workflow becomes a TypeScript Server Action with a Zod schema for input and a typed return value. The logic stays the same; the runtime is now code under source control with tests where they matter.

  4. 04

    Rebuild the UI with the design system

    The Bubble pages get redesigned (rarely a one-to-one copy is the right call) and built with the design system. The new screens are accessible, mobile-friendly, dark-mode-capable and consistent in a way Bubble's visual builder could not enforce.

  5. 05

    Migrate user accounts

    Bubble auth migrates to Supabase Auth. Existing users receive a password reset email on cutover day; the original user IDs are preserved so historical references in the data stay intact. Social-login users keep their providers.

  6. 06

    Cutover with a rollback plan

    DNS flip on a Saturday, parallel-running for a defined window, a documented rollback path until both sides sign off. Bubble keeps running in read-only mode for thirty days as a fallback before final decommissioning.

What we deliver

Postgres data model

Schema translated from Bubble with foreign keys, indexes and row-level security policies in source-controlled migrations.

User account migration

Bubble users imported into Supabase Auth, original IDs preserved, password reset link sent on cutover.

Auth replacement

Supabase Auth with social providers, magic links, MFA and SSO where the buyer needs it.

Business logic as Server Actions

Every Bubble workflow rebuilt as a typed TypeScript Server Action with input validation and transactional database writes.

Admin dashboard

The internal cockpit the Bubble editor gave for free, rebuilt as a proper admin surface with the design system.

Email pipeline

Transactional and lifecycle emails moved from Bubble's plugin to Resend with templates under source control.

File storage migration

Bubble's stored files moved to Cloudflare R2 with signed URLs and per-tenant prefixes.

Stripe billing if applicable

Subscriptions, Customer Portal, prorating, webhook event router. Existing billing relationships migrated to Stripe.

Multi-locale support

Hreflang and locale-aware URLs from the first commit. Three languages by default where Bubble usually shipped one.

Performance baseline

Core Web Vitals measured against the Bubble baseline, with the new app outperforming on every metric.

Documentation for the new codebase

Runbooks, architecture overview, design system as a versioned package. The next team picks it up without a guided tour.

Bubble decommissioning checklist

Step-by-step plan for cancelling the Bubble subscription, exporting historical data, ending vendor lock-in.

Translating a Bubble workflow into a typed Server Action

A Bubble "When a button is clicked" workflow with five steps becomes one TypeScript Server Action with a Zod schema, a database transaction and an explicit error path. The shape of the action is reviewable in source control; the visual editor that hid the logic disappears.

The hard part of a Bubble migration is not the database schema (Bubble's tables map cleanly to Postgres) or the UI (the new screens are usually better redesigned than copied). The hard part is the business logic that lives inside Bubble's visual workflow editor. Each workflow is a tree of conditions, database writes, API calls and side effects that the team has been editing for months. The translation has to preserve every behaviour while turning the tree into code that another engineer can read.

The pattern below is what we ship: a Bubble workflow ("When a user clicks Upgrade to Pro") rewritten as a single typed Server Action. The Zod schema validates the input, the transaction wraps the writes, the error path is explicit, the result type is what the caller switches on. The visual editor that hid the logic is replaced with code that lives in source control.

1. The original Bubble workflow

In Bubble, the upgrade flow looks like a vertical column of steps in the workflow editor. Reading it requires opening each step, reading its conditions, looking at its database actions, and tracking which fields each step writes to. Below is the same logic written as TypeScript.

2. The translated Server Action

// app/[lang]/(app)/account/actions.ts
'use server'

import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { createServerClient } from '@/lib/supabase/server'
import { stripe } from '@/lib/stripe/server'
import { getServerSession } from '@/lib/auth/server'

const Input = z.object({
  targetPlan: z.enum(['pro', 'team']),
})

export type UpgradeResult =
  | { tag: 'success'; subscriptionId: string }
  | { tag: 'already_on_target'; currentPlan: string }
  | { tag: 'no_payment_method'; portalUrl: string }
  | { tag: 'error'; message: string }

export async function upgradePlan(raw: unknown): Promise<UpgradeResult> {
  const session = await getServerSession()
  if (!session) return { tag: 'error', message: 'unauthorised' }

  const parsed = Input.safeParse(raw)
  if (!parsed.success) return { tag: 'error', message: 'invalid input' }

  const supabase = await createServerClient()

  // Step 1: read the current subscription. In Bubble this is "Get current
  // user's subscription"; in TypeScript it is a single typed query.
  const { data: subscription, error: readError } = await supabase
    .from('subscriptions')
    .select('id, plan, stripe_subscription_id, status')
    .eq('tenant_id', session.tenantId)
    .single()
  if (readError) return { tag: 'error', message: 'subscription not found' }

  // Step 2: guard the no-op case. In Bubble this was an "Only when" condition
  // on the workflow trigger; here it is a precondition before any side effect.
  if (subscription.plan === parsed.data.targetPlan) {
    return { tag: 'already_on_target', currentPlan: subscription.plan }
  }

  // Step 3: verify the payment method. In Bubble this was a separate plugin
  // call followed by a conditional branch; here it is a single Stripe lookup.
  const customer = await stripe.customers.retrieve(
    subscription.stripe_subscription_id,
  )
  if ('deleted' in customer || !customer.invoice_settings?.default_payment_method) {
    const portal = await stripe.billingPortal.sessions.create({
      customer: subscription.stripe_subscription_id,
      return_url: `${process.env.SITE_URL}/account`,
    })
    return { tag: 'no_payment_method', portalUrl: portal.url }
  }

  // Step 4: update the Stripe subscription. The proration and invoice
  // generation are Stripe's responsibility, not ours.
  const updated = await stripe.subscriptions.update(
    subscription.stripe_subscription_id,
    {
      items: [{ price: PRICE_LOOKUP[parsed.data.targetPlan] }],
      proration_behavior: 'create_prorations',
      metadata: { tenant_id: session.tenantId, plan: parsed.data.targetPlan },
    },
  )

  // Step 5: update the local subscription row. The webhook will fire too and
  // confirm the same write; the row is idempotent.
  const { error: writeError } = await supabase
    .from('subscriptions')
    .update({
      plan: parsed.data.targetPlan,
      status: updated.status,
      updated_at: new Date().toISOString(),
    })
    .eq('id', subscription.id)
  if (writeError) return { tag: 'error', message: 'database write failed' }

  revalidatePath('/account')
  return { tag: 'success', subscriptionId: updated.id }
}

const PRICE_LOOKUP: Record<'pro' | 'team', string> = {
  pro: process.env.STRIPE_PRICE_PRO!,
  team: process.env.STRIPE_PRICE_TEAM!,
}

3. What the team sees afterwards

Reviewing this Server Action is a code review of one file. Reading the original Bubble workflow was a click-through of five tabs in the visual editor. Catching a bug in the new version is a TypeScript compile error or a missing test; catching a bug in the Bubble version was hoping someone noticed before a customer did.

The migration is not a rewrite for the sake of it. It is a translation from a runtime that hides the logic into a runtime where the logic is the source code. The product behaviour stays the same; the audit trail, the type safety and the engineering practices that the team can hire for become possible.

4. What the user never notices

Done well, the migration is invisible to the people using the product. The screens load faster, the password reset email arrives once on cutover day, and the next sign-in feels exactly like the last one. The engineering work is large; the user-visible event is "the app feels snappier today". That is the bar.

Frequently asked questions

Should we migrate or extend Bubble?

Migrate when the product is working and the cost of staying on Bubble (performance, hiring, vendor lock-in) outweighs the cost of the rewrite. Extend when you are still proving the idea and the Bubble baseline is good enough. The honest answer takes a scoping call; we will tell you straight which side the line falls on for your case.

How long does a Bubble migration take?

Four to eight weeks for a typical greenfield SaaS migration. The exact number depends on the Bubble app's data-model complexity, the number of distinct workflows, the third-party integrations and the user base size. The scoping phase produces a number you can plan a budget around.

How does it compare in cost to the Bubble subscription?

The migration is a one-time investment; the ongoing cost after migration is the application infrastructure (Vercel, Supabase, Stripe), which scales linearly with use and stays predictable. Bubble's monthly fee disappears. We model the payback period in scoping, usually six to twelve months.

Can users keep their accounts?

Yes. We import the existing users into Supabase Auth preserving their original IDs so historical references stay intact. Email-and-password users get a password reset link on cutover day; social-login users keep their providers without a re-link.

What about the Bubble plugins we depend on?

Each plugin gets mapped to a TypeScript equivalent during the audit. Most plugins (Stripe, SendGrid, Twilio, AWS S3, Google Sheets) have direct counterparts in our standard stack. Edge cases get scoped explicitly so the cost is visible up front.

How do we preserve data integrity through the cutover?

Parallel-run with the source-of-truth still Bubble for a documented window. The new app reads from a synced copy of the data; once the team signs off, the source-of-truth flips. The Bubble app stays read-only for thirty days as a fallback before final decommissioning.

Who owns the code after migration?

You. The contract assigns IP on delivery. The source lives in your repositories, the infrastructure in your accounts, the design system as a versioned package. Any developer with TypeScript and Next.js experience can pick the codebase up after we hand over.

Tell us about your Bubble app

A scoping call, a concrete number in the first reply, no agency theater. Most Bubble migrations ship in four to eight weeks.