Caso de uso · De Bubble a producción

De Bubble a un codebase de verdad, sin perder a los usuarios

Reescribimos la app Bubble sobre Next.js, Supabase y TypeScript strict. Los workflows se convierten en Server Actions tipadas, el modelo de datos pasa a Postgres con row-level security, y los usuarios cruzan al sistema nuevo con un reset de contraseña el día del cutover.

El problema

Bubble funciona, hasta que ya no llega

Bubble funciona para un primer MVP. El builder visual envía un producto que arranca, la lógica no-code sobrevive a la primera ronda de feedback y el founder prueba la idea sin tener que contratar ingenieros. El problema empieza cuando el producto empieza a funcionar de verdad. El rendimiento no escala, la business logic se vuelve ilegible dentro del editor visual de workflows, el vendor lock-in aprieta, y cada ingeniero con el que el founder habla se da media vuelta en cuanto oye "está hecho en Bubble". El MVP que demostró la idea es lo mismo que hoy le impide a la empresa ir más allá.

Cómo lo abordamos

Los seis pasos de una migración Bubble

Cada paso tiene una entrega que el equipo puede ver. El cutover ocurre en un solo día con un plan de rollback documentado, no un viernes por la tarde con una oración.

  1. 01

    Auditar la app Bubble

    Mapeamos el modelo de datos (tablas de Bubble a tablas Postgres), los workflows (cada uno pasa a ser una Server Action), el setup de auth, las integraciones de terceros y la base de usuarios viva. La auditoría produce un scope escrito y un número antes de que se escriba una línea de código.

  2. 02

    Traducir el modelo de datos a Postgres

    Cada tabla de Bubble se convierte en una tabla Postgres con foreign keys, índices y policies de row-level security adecuadas. Las relaciones implícitas en Bubble pasan a ser constraints explícitos en SQL. El schema se convierte en algo que un ingeniero puede leer.

  3. 03

    Portar la business logic a Server Actions tipadas

    Cada workflow de Bubble se vuelve una Server Action TypeScript con un schema Zod para el input y un valor de retorno tipado. La lógica se mantiene; el runtime ahora es código bajo source control con tests donde importan.

  4. 04

    Reconstruir la UI con el design system

    Las páginas de Bubble se rediseñan (rara vez tiene sentido copiarlas una a una) y se construyen con el design system. Las pantallas nuevas son accesibles, mobile-friendly, capaces de dark mode, y consistentes en un modo que el builder visual de Bubble no podía garantizar.

  5. 05

    Migrar las cuentas de usuario

    El auth de Bubble migra a Supabase Auth. Los usuarios existentes reciben un email de reset de contraseña el día del cutover; los IDs de usuario originales se preservan para que las referencias históricas en los datos se queden intactas. Los usuarios con social login mantienen sus proveedores.

  6. 06

    Cutover con plan de rollback

    DNS flip un sábado, parallel-running durante una ventana definida, un camino de rollback documentado hasta que las dos partes firmen. Bubble sigue corriendo en read-only durante treinta días como fallback antes del decommissioning final.

Qué entregamos

Modelo de datos Postgres

Schema traducido desde Bubble con foreign keys, índices y policies de row-level security en migrations en source control.

Migración de cuentas de usuario

Usuarios de Bubble importados a Supabase Auth, IDs originales preservados, enlace de reset de contraseña enviado al cutover.

Sustitución del auth

Supabase Auth con social providers, magic links, MFA y SSO cuando el buyer lo pide.

Business logic como Server Actions

Cada workflow de Bubble reconstruido como Server Action TypeScript tipada con validación de input y escrituras de base de datos transaccionales.

Dashboard de administración

El cockpit interno que el editor de Bubble daba gratis, reconstruido como superficie de admin real con el design system.

Pipeline de email

Emails transaccionales y de lifecycle movidos del plugin de Bubble a Resend con plantillas bajo source control.

Migración de file storage

Los archivos guardados por Bubble movidos a Cloudflare R2 con signed URLs y prefijos por tenant.

Billing Stripe si aplica

Subscriptions, Customer Portal, prorating, webhook event router. Relaciones de billing existentes migradas a Stripe.

Soporte multilenguaje

Hreflang y URLs localizadas desde el primer commit. Tres idiomas por defecto donde Bubble normalmente enviaba uno.

Baseline de performance

Core Web Vitals medidos contra la baseline de Bubble, con la app nueva que mejora cada métrica.

Documentación del codebase nuevo

Runbooks, vista general de arquitectura, design system como paquete versionado. El siguiente equipo lo recoge sin visita guiada.

Checklist de decommissioning de Bubble

Plan paso a paso para cancelar la suscripción Bubble, exportar datos históricos, cerrar el vendor lock-in.

Traducir un workflow de Bubble a una Server Action tipada

Un workflow tipo "When a button is clicked" en Bubble con cinco pasos se convierte en una sola Server Action TypeScript con un schema Zod, una transacción de base de datos y un camino de error explícito. La forma de la action es revisable en source control; el editor visual que escondía la lógica desaparece.

La parte difícil de una migración desde Bubble no es el schema de la base de datos (las tablas de Bubble mapean limpio a Postgres) ni la UI (las pantallas nuevas suelen estar mejor rediseñadas que copiadas). La parte difícil es la business logic que vive dentro del editor visual de workflows de Bubble. Cada workflow es un árbol de condiciones, escrituras de base de datos, llamadas a API y efectos colaterales que el equipo lleva meses editando. La traducción tiene que preservar cada comportamiento mientras convierte el árbol en código que otro ingeniero pueda leer.

El patrón de abajo es el que enviamos: un workflow de Bubble ("When a user clicks Upgrade to Pro") reescrito como una sola Server Action tipada. El schema Zod valida el input, la transacción envuelve las escrituras, el camino de error es explícito, el tipo de retorno es el que el caller pone en el switch. El editor visual que escondía la lógica se sustituye por código que vive en source control.

1. El workflow Bubble original

En Bubble, el flujo de upgrade se ve como una columna vertical de pasos en el editor de workflows. Leerlo requiere abrir cada paso, leer sus condiciones, mirar sus acciones de base de datos y trazar qué campos escribe cada paso. Abajo está la misma lógica escrita en TypeScript.

2. La Server Action traducida

// 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()

  // Paso 1: lee la subscription actual. En Bubble es "Get current user's
  // subscription"; en TypeScript es una sola query tipada.
  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' }

  // Paso 2: guarda contra el caso no-op. En Bubble era una condición
  // "Only when" en el trigger del workflow; aquí es una precondición antes
  // de cualquier side effect.
  if (subscription.plan === parsed.data.targetPlan) {
    return { tag: 'already_on_target', currentPlan: subscription.plan }
  }

  // Paso 3: verifica el método de pago. En Bubble era una llamada de plugin
  // aparte seguida de una rama condicional; aquí es un solo lookup en Stripe.
  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 }
  }

  // Paso 4: actualiza la subscription Stripe. La prorrata y la generación
  // de la factura son responsabilidad de Stripe, no nuestra.
  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 },
    },
  )

  // Paso 5: actualiza la fila local de subscription. El webhook también
  // dispara y confirma la misma escritura; la fila es idempotente.
  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. Qué cambia para el equipo

Revisar esta Server Action es una code review de un solo archivo. Leer el workflow Bubble original obligaba a navegar por cinco pestañas del editor visual. Atrapar un bug en la versión nueva es un error de compilación en TypeScript o un test que falta; atrapar un bug en la versión Bubble era esperar a que alguien se diera cuenta antes de que lo hiciera un cliente.

La migración no es una reescritura por sí misma, es una traducción desde un runtime que esconde la lógica a un runtime donde la lógica es el código fuente. El comportamiento del producto se mantiene; lo que se vuelve posible es el audit trail, la type safety, y todas las prácticas de ingeniería con las que el equipo puede por fin contar de verdad.

4. Qué no nota el usuario

Bien hecha, la migración es invisible para la gente que usa el producto. Las pantallas cargan más rápido, el email de reset de contraseña llega una sola vez el día del cutover, y el siguiente sign-in es idéntico al anterior. El trabajo de ingeniería es enorme; el evento visible al usuario es "la app hoy se siente más ágil". Y esa es la vara que nos ponemos.

Preguntas frecuentes

¿Migramos o extendemos Bubble?

Migrar cuando el producto funciona y el coste de quedarse en Bubble (rendimiento, hiring, vendor lock-in) pesa más que el coste de la reescritura. Extender cuando todavía estás probando la idea y la baseline de Bubble alcanza. La respuesta honesta necesita una llamada de scoping; te decimos enseguida dónde cae la línea en tu caso.

¿Cuánto dura una migración desde Bubble?

Cuatro a ocho semanas para una migración SaaS greenfield típica. El número exacto depende de la complejidad del modelo de datos en Bubble, el número de workflows distintos, las integraciones de terceros y el tamaño de la base de usuarios. La fase de scoping produce un número con el que planificar un presupuesto.

¿Cómo se compara en coste con la suscripción a Bubble?

La migración es una inversión one-time; el coste continuo después de la migración es la infraestructura aplicativa (Vercel, Supabase, Stripe), que escala linealmente con el uso y se mantiene predecible. La cuota mensual de Bubble desaparece. Modelamos el payback period en scoping, normalmente seis a doce meses.

¿Los usuarios pueden conservar sus cuentas?

Sí. Importamos los usuarios existentes a Supabase Auth preservando sus IDs originales para que las referencias históricas queden intactas. Los usuarios con email-y-contraseña reciben un enlace de reset el día del cutover; los de social login mantienen su proveedor sin necesidad de re-vincular.

¿Y los plugins de Bubble de los que dependemos?

Cada plugin se mapea a un equivalente en TypeScript durante la auditoría. La mayoría de plugins (Stripe, SendGrid, Twilio, AWS S3, Google Sheets) tienen contrapartes directas en nuestro stack estándar. Los casos límite se escopan explícitamente para que el coste sea visible desde el principio.

¿Cómo preservamos la integridad de los datos durante el cutover?

Parallel-run con la fuente de verdad todavía en Bubble durante una ventana documentada. La app nueva lee desde una copia sincronizada de los datos; cuando el equipo firma, la fuente de verdad cambia de lado. La app Bubble se queda en read-only treinta días como fallback antes del decommissioning final.

¿De quién es el código después de la migración?

Tuyo. El contrato cede la propiedad intelectual en la entrega. La fuente vive en tus repositorios, la infraestructura en tus cuentas, el design system como paquete versionado. Cualquier desarrollador con experiencia en TypeScript y Next.js puede recoger el codebase tras la entrega.

Cuéntanos tu app Bubble

Una llamada de scoping, un número concreto en la primera respuesta, sin teatro de agencia. La mayoría de migraciones Bubble salen en cuatro u ocho semanas.