Ir al contenido

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.

  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

Cuéntanos tu app Bubble

La mayoría de migraciones Bubble salen en cuatro u ocho semanas.