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.