Todos los proyectos
Live en producciónProyecto 01
SaaSMulti-tenanteCommerce

BioLinkStore

Una URL que convierte tu catálogo en una tienda, para quien vende por Instagram y cierra por WhatsApp. Se monta en minutos, sin tarjeta, con infraestructura a $0/mes y sin sacrificar arquitectura.

Next.jsNestJSPrismaPostgreSQL (Supabase)VercelKoyeb

El link de la bio de Instagram tiene un solo trabajo. BioLinkStore lo convierte en una tienda completa.

Catálogo con fotos, variantes y stock, tres plantillas, y el pedido que le llega al vendedor por WhatsApp ya escrito. Sin comisión por venta y con infraestructura a $0/mes.

Perfil de Instagram
noire.boutique
ropa · envíos a todo el país
Pedidos por el link
biolinkstore.com/noire
→
La misma URL, abierta
Camisa lino
US$ 18
Vestido midi
US$ 32
Pantalón wide
US$ 24
Talla S · M · LColorStock
Pedir por WhatsApp
3:02min:s hasta tienda publicada, cronometrado en la demo grabada
$0al mes en infraestructura: cuatro proveedores en free tier
0 %de comisión por venta, en todos los planes
01 · Cómo funciona

De un toque en la bio a un pedido en WhatsApp

Pulsa los pasos o deja que avance. Cada paso ilumina la pieza que trabaja.

Bio de Instagram
un solo link
Next.js
/[slug] · SSR en Vercel
API NestJS
/api/public/:slug
PostgreSQL
un esquema, N tiendas
WhatsApp
el pedido, escrito
Paso 1 de 5 · avanza solo
El cliente toca el link de la bio
La URL es biolinkstore.com/<tienda>. No hay app que instalar ni cuenta que crear del lado del comprador: es una página.
El alta son cuatro pantallas: nombre de usuario (se comprueba en vivo que el link esté libre), nombre de la tienda, categorías y plan. Del lado del comprador no hay cuenta ni carrito persistente: el pedido es un mensaje, y el vendedor lo cierra como siempre lo hizo.
02 · Arquitectura

Cuatro capas, cuatro proveedores, un solo esquema

Cada capa vive en el proveedor que la sirve gratis. El acoplamiento entre ellas pasa por HTTPS y un contrato de API documentado en Swagger.

Topología en producción
Edge
Cloudflare
DNS + proxy del dominio
free
HTTPS
Front
Vercel · Next.js
/[slug] SSR + Open Graph
24 páginas
BFF en Next
cookies httpOnly + /api/proxy
tokens fuera del navegador
Bearer JWT · CORS con lista blanca
API
Koyeb · NestJS
14 módulos · 47 endpoints
hexagonal
Guards
JWT · StoreOwner · PlanFeature · Demo
4 en cadena
Prisma · pgBouncer
Datos
Supabase
PostgreSQL · 19 modelos, un esquema
multi-tenant
Storage
local · S3 · Cloudinary
una interfaz
Resend
bienvenida · reset
React Email

Dentro del API, cada módulo sigue la misma línea: Controller → Use case → Interfaz de repositorio → Repositorio Prisma. Son 44 casos de uso de una responsabilidad cada uno. El dominio no importa Prisma.

Multi-tenant · un esquema, una fila por tienda
Una ruta
app/[slug]/page.tsx
Cada tienda es un valor del parámetro, no un despliegue.
slug
template
whatsapp
noire
Noir
+58 4…
brooklyn
Vitrina
+58 4…
andrea
Luxora
+58 4…
tabla stores · slug UNIQUE · índice por slug
Noir
ropa · paleta propia
Vitrina
comida · paleta propia
Luxora
accesorios · paleta propia
La ruta pide /api/public/:slug y el renderer mapea la columna template a un componente. Una tienda nueva es un INSERT, no un deploy. Los nombres de tienda son de ejemplo.
Auth · los tokens no tocan el navegador
1 · NestJS
Valida credenciales y emite dos JWT propios: acceso de 15 min y refresh de 7 días. El refresh se persiste en la tabla refresh_tokens.
2 · BFF · Next
La ruta /api/auth/login del propio Next recibe los tokens y los guarda en cookies httpOnly. Deja además una cookie sin secreto, igs_session, que el middleware Edge sí puede leer.
3 · Navegador
Nunca ve un token. El código del cliente llama a /api/proxy/… y el proxy inyecta el Bearer desde la cookie.
4 · Proxy
Si el API devuelve 401, el proxy llama a /api/auth/refresh, rota las cookies y reintenta la petición original. Si el refresh falla, el cliente cierra sesión.
5 · Guards
En el API, tres guardias en cadena: JWT global (con @Public() para salir), StoreOwnerGuard (la tienda es tuya) y PlanFeatureGuard (tu plan incluye esto).
03 · Un ejemplo de punta a punta

María abre su tienda un martes por la tarde

Un caso inventado con números pequeños, para ver qué pasa en cada tramo. Los nombres y las cantidades son de ejemplo.

1
16:00 · alta
Elige su link y está libre
María vende ropa por Instagram. Escribe maria-closet; el panel consulta /stores/check-username y confirma que nadie lo tiene. Nombre, categorías, plan Gratis. La tienda existe.
2
16:10 · catálogo
Tres productos, uno con variantes
Sube una camisa con atributos Talla S · M · L y Color Negro · Blanco. El servicio de variantes genera las 6 combinaciones; ella solo ajusta el precio de la L (+US$ 2) y el stock de cada una. Las fotos salen con una marca de agua discreta: es lo que el plan Gratis incluye.
3
16:20 · diseño
Elige Vitrina y sus colores
Prueba ?preview=noir, vuelve a Vitrina, fija su paleta. Pega biolinkstore.com/maria-closet en la bio. La previsualización sale con su logo porque la página trae Open Graph.
4
19:42 · un cliente
Dos camisas M negras
Alguien entra desde la bio, elige talla y color, y pide. El backend crea el OrderIntent: 2 × (18 + 0) = US$ 36, genera el mensaje y la URL de WhatsApp. El comprador aterriza en el chat de María con el pedido escrito.
5
19:43 · panel
La cotización ya está en su lista
En Cotizaciones aparece el pedido con el número del cliente y un botón para abrir el chat. En Estadísticas, la visita, la vista de producto y el checkout quedaron registrados como eventos. María despacha por WhatsApp, como siempre.
04 · Los retos

Cinco veces en que la solución obvia estaba mal

Cada reto sigue el mismo patrón: lo que cualquiera haría primero, por qué no sirve, lo que se hizo y el número que lo demuestra. El detalle técnico queda plegado.

01

Muchas tiendas, un solo esquema

Lo obvio
Un esquema de PostgreSQL por tienda, o una base de datos por cliente. Aislamiento perfecto.
Por qué falla
Con presupuesto cero y una sola instancia de Supabase, cada esquema nuevo es una migración más que correr y un pool de conexiones más que pagar. Y el problema real no es el esquema: es que en un esquema compartido una query sin filtro de tienda no falla. Devuelve datos ajenos en silencio.
Compila · pasa los tests · filtra
prisma.product.findMany({ where: { id: productId } })
Devuelve el producto aunque sea de otra tienda. Ningún error lo delata.
Lo que exige cada repositorio
prisma.product.findMany({ where: { id: productId, storeId } })
El storeId llega como parámetro obligatorio, ya validado por el guard.
Lo que se hizo
Dos capas que no dependen de la memoria de nadie: StoreOwnerGuard comprueba que la tienda de la URL pertenece al usuario del token antes de entrar al caso de uso, y los repositorios reciben el storeId como parámetro obligatorio, nunca lo deducen. Las rutas públicas resuelven por slug, que es único e indexado.
1 esquemapara todas las tiendas. Un cliente nuevo es una fila, no una migración.
Ver detalle técnico

Prisma no expone Row Level Security. La disciplina vive en el código: cada repositorio nuevo debe aceptar storeId y usarlo en el where. Es sostenible mientras el equipo es pequeño y peligroso cuando deja de serlo; por eso está en la lista de lo que hoy se haría distinto.

Las rutas anidadas son explícitas, /api/stores/:storeId/products, para que el guard tenga siempre el identificador en la URL y no en el cuerpo de la petición.

02

Talla, color y 64 filas

Lo obvio
Guardar cada variante como un JSON: { "Talla": "M", "Color": "Negro" }. Flexible, sin tablas nuevas, cualquier atributo cabe.
Por qué falla
Cuando el comprador elige Talla y Color hay que encontrar esa fila comparando objetos JSON, sin un índice que sirva. Con 3 atributos de 4 valores son 64 combinaciones por producto, resueltas en tiempo de petición. Y renombrar un atributo es migrar datos dentro de un JSON.
2 atributos × 2 valores
S · Negro
S · Blanco
M · Negro
M · Blanco
4 filas · se leen de un vistazo
3 atributos × 4 valores
64 filas · encontrar una es comparar JSON
Lo que se hizo
Las combinaciones no las escribe el vendedor: VariantGeneratorService hace el producto cartesiano de los atributos y crea las filas. El precio final es basePrice + priceAdjustment de la variante, y el stock vive por variante. El nombre que sale en el mensaje de WhatsApp se compone al vuelo: M / Negro.
64combinaciones posibles para 3 atributos × 4 valores. El generador las crea; el matching sigue siendo JSON.
Ver detalle técnico

El campo combination es Json en el esquema. Funciona para el volumen actual (el plan Gratis tope en 20 productos, el Pro en 100) y es la deuda más clara del modelo de datos: una tabla ProductVariantAttribute con una fila por par atributo-valor sería indexable y consultable con SQL normal.

03

El error que no da error

Lo obvio
Apuntar DATABASE_URL a Supabase, correr prisma migrate deploy en el arranque y listo. En local funciona.
Por qué falla
Supabase entrega la conexión a través de pgBouncer en modo transaction pooling, que no conserva estado de sesión entre queries. Prisma usa prepared statements y la migración pide un advisory lock, dos cosas que exigen sesión. Resultado: migrate deploy se queda colgado. Sin timeout, sin stacktrace, sin log. Cada intento costaba un ciclo de build.
DATABASE_URL
…:6543/postgres?pgbouncer=true
Runtime: Prisma deja de asumir sesión y deshabilita los prepared statements.
DIRECT_URL
…:5432/postgres
Migraciones: el advisory lock necesita una conexión directa, sin pooler.
migrate deploy
desde la máquina local
El 5432 no es alcanzable desde el build de Koyeb. La migración sale del portátil, el deploy solo arranca.
Lo que se hizo
Tres cambios, cada uno por una razón distinta: ?pgbouncer=true en la URL de runtime, directUrl al puerto 5432 para migraciones, y las migraciones se ejecutan desde local porque ese puerto no es alcanzable desde el entorno de build de Koyeb.
~5 minpor ciclo de build sin ningún mensaje útil. El reto más caro del proyecto en tiempo.
Ver detalle técnico

La documentación de Prisma y la de Supabase mencionan el caso por separado; ninguna describe el efecto combinado en un despliegue real. Es el tipo de problema que solo aparece en producción y que conviene dejar por escrito para el siguiente proyecto sobre el mismo stack.

04

Los tokens no tocan el navegador

Lo obvio
Guardar el JWT en localStorage y mandarlo desde el cliente con un interceptor. Es lo que hacen la mayoría de los tutoriales.
Por qué falla
Cualquier script que corra en la página lee localStorage. Y en este despliegue el front (Vercel) y el API (Koyeb) son orígenes distintos: cada llamada desde el navegador es una negociación CORS más, con credenciales, que ya costó tiempo de producción una vez.
Lo que se hizo
Un BFF dentro del propio Next: /api/auth/login guarda acceso y refresh en cookies httpOnly, y /api/proxy/[...path] reenvía cada llamada del panel al API inyectando el Bearer. Ante un 401, el proxy refresca, rota las cookies y reintenta la petición original. El navegador solo ve una cookie sin secreto, igs_session, que el middleware Edge usa para redirigir sin llamar al API.
15 minde vida del token de acceso. 7 días el de refresh, revocable porque vive en una tabla.
Ver detalle técnico

El callback de Google OAuth es la excepción incómoda: el redirect 302 vuelve del API al front con los tokens en la query string, y una página /login/oauth los convierte en cookies. Un ref evita que el useEffect los consuma dos veces en Strict Mode. Es una de las cosas de la lista de hoy-lo-haría-distinto.

05

Cobrar planes sin que el código lo sepa

Lo obvio
Un if (plan === 'PRO') en cada endpoint que dependa del plan.
Por qué falla
Tres planes por una docena de capacidades son treinta y seis decisiones repartidas por el código. Cambiar un límite comercial, subir el tope de productos del plan Gratis, obliga a buscar cada if.
Lo que se hizo
Una sola tabla de verdad, PLAN_FEATURES: por plan, cuántos productos, cuántas fotos, dominio propio, marca de agua, analíticas, multi-tienda. Un guard lee el decorador del endpoint, busca la suscripción de la tienda y decide. El límite de productos, la marca de agua en las fotos subidas y la marca powered by salen de la misma tabla.
3 planesGratis $0 · Pro $15/mes · Business $150/mes. 0 % de comisión en los tres.
Ver detalle técnico

Gratis: 20 productos, 5 fotos por producto, 5 categorías, checkout por WhatsApp, analíticas básicas. Pro: 100 productos, 10 fotos, sin marca de agua, plantillas Pro, exportar cotizaciones. Business: ilimitado y hasta 3 tiendas. Los precios de la landing se muestran también en bolívares con la tasa oficial del día, leída de una API pública y cacheada en el cliente.

Hay además un DemoGuard: las cuentas marcadas como demo pueden leer todo y escribir nada, salvo una lista de correos exentos. Sirve para enseñar el panel sin que nadie rompa la tienda de muestra.

"El pedido es un mensaje."
No hay pasarela de pago ni carrito persistente del lado del comprador. El vendedor ya cobra por Pago Móvil, Zelle o efectivo y ya cierra por WhatsApp; el producto no le cambia eso, le quita el trabajo de escribir el catálogo doce veces al día.
05 · Decisiones

Un solo filtro: ¿esto cabe en el free tier sin regalar arquitectura?

Si la respuesta era no, se buscó otra pieza. Si era sí, se abstrajo lo justo para poder cambiarla después.

Cloudflare
DNS y proxy
Vercel
Next.js SSR
Koyeb
API NestJS
Supabase
PostgreSQL
Resend
email transaccional
Storage
local · S3 · Cloudinary
La restricción de diseño fue una sola: cada decisión de infraestructura tenía que maximizar capacidad técnica dentro del free tier de su proveedor. Eso convierte el stack en una prueba de conocimiento del ecosistema, no de preferencia.
WHY
¿Por qué un API NestJS aparte, en vez de API Routes de Next?
Porque el backend asume un proceso de larga vida con inyección de dependencias real: guards globales, interceptores, filtros de excepciones, estrategias de Passport, una abstracción de storage. Meter eso en funciones serverless es perder el contenedor de NestJS o emularlo a mano.
Profundizar

La segunda razón es de escala: el front se sirve desde el edge de Vercel y el API escala aparte. En un SaaS multi-tenant los picos de tráfico de dos tiendas no están correlacionados.

El coste asumido fue CORS y latencia entre servicios. Apareció en producción y costó tiempo; el BFF del reto 04 es la respuesta.
WHY
¿Por qué Prisma y no Drizzle o TypeORM?
Tipado de punta a punta desde el esquema hasta la query, un cliente generado predecible y migraciones deterministas. TypeORM tiene problemas conocidos con strict y sus decoradores; Drizzle era excelente pero su tooling de migraciones estaba menos maduro al arrancar.
Profundizar
El trade-off apareció en producción: Prisma asume conexiones directas y no está pensado para poolers como pgBouncer sin configuración explícita. Es el reto 03.
WHY
¿Por qué Resend y no el email de Supabase o SendGrid?
El email de Supabase está pensado para su propio Auth, y aquí el auth es JWT propio con Passport. SendGrid es sólido pero su configuración no compensa para el volumen actual. Resend tiene SDK de TypeScript de primera clase y plantillas en React Email: el 90 % del resultado con el 20 % de la configuración.
WHY
Una interfaz de storage desde el día uno
StorageProvider con tres implementaciones: local para desarrollo, S3-compatible y Cloudinary. En un SaaS donde cada tienda sube sus fotos, el proveedor de almacenamiento es una decisión de costos que va a cambiar. Cambiarlo es una variable de entorno, no un refactor.
Profundizar

Por el mismo camino pasa la marca de agua: sharp compone un SVG sobre la imagen antes de subirla, solo si el plan de la tienda no incluye quitarla. Los GIF y los PDF se saltan el paso.

06 · Qué haría distinto

Cinco decisiones que hoy tomaría al revés

Hice
Aislamiento por storeId a mano en cada repositorio
Hoy haría
Row Level Security de PostgreSQL. Una política por tabla hace el aislamiento imposible de saltar sea cual sea el ORM. Supabase lo soporta de primera clase.
Hice
ProductVariant.combination como JSON
Hoy haría
Tabla ProductVariantAttribute con una fila por par atributo-valor. Indexable, consultable con SQL normal, sin migrar JSON si se renombra un atributo.
Hice
Refresh tokens en una tabla de PostgreSQL
Hoy haría
Redis con TTL. La expiración la maneja el store solo, sin escrituras frecuentes en Postgres ni jobs de limpieza, lecturas O(1).
Hice
Tokens de OAuth en la query string del redirect
Hoy haría
Cerrar el callback en el propio BFF: que el API redirija a una ruta de Next que fije las cookies en el servidor, y los tokens no pasen por la URL ni por el historial.
Hice
Carpetas del front por tipo (components/, lib/)
Hoy haría
Estructura por dominio: features/auth, features/store, features/products. La misma cohesión que ya tienen los módulos del backend.
07 · Si te llevas tres cosas

Recap

1
Un link en la bio que es una tienda: catálogo con variantes y stock, tres plantillas, y el pedido llega por WhatsApp ya escrito. Sin comisión por venta.
2
Multi-tenant en un esquema: una tienda nueva es una fila, no un deploy. El aislamiento lo garantizan un guard y un parámetro obligatorio, y hoy se haría con RLS.
3
Infraestructura a $0/mes en cuatro proveedores, con los tokens fuera del navegador y una tabla única de planes. El reto más caro fue un pooler que cuelga sin decir nada.
Enlaces
Ver en vivo