Caso Real: SaaS de Captación de Clientes con NestJS, React y React Native
Atrapaclientes: plataforma SaaS multitenant con 155 endpoints, 29 entidades, 74 migraciones, RBAC con 31 permisos, WebSockets, email marketing, SMS/WhatsApp Twilio, Club Infantil, talleres, ludoteca y app kiosko en React Native. Desplegado en producción en atrapaclientes.es.
Tabla de contenidos
TL;DR
Construí Atrapaclientes, una plataforma SaaS multitenant completa en 2 meses. Backend con NestJS (155 endpoints, 25 módulos, RBAC con 31 permisos, WebSockets, email marketing, SMS/WhatsApp Twilio), frontend React 19 con 30 páginas, app kiosko con React Native en producción, PostgreSQL 17 con 74 migraciones y despliegue con Docker + Coolify en VPS propio en atrapaclientes.es. Aquí explico las decisiones técnicas, los retos reales y cómo los resolví.
<video src=“/videos/atrapaclientes.mp4” controls muted playsinline class=“w-full rounded-xl my-6 shadow-lg” style=“max-height: 480px; object-fit: cover;”
Tu navegador no soporta vídeo HTML5.
El problema
Un negocio necesitaba captar datos de clientes en el punto de venta físico (centros comerciales, tiendas, eventos) mediante tablets interactivas con campañas atractivas: sorteos, descuentos, Club Infantil. Las soluciones existentes eran caras, sin personalización y sin control sobre los datos propios.
Requisitos reales:
- Cada cliente (tenant) debe ver solo sus datos — aislamiento total
- Tablets en tiendas mostrando campañas, recogiendo formularios en tiempo real
- Generación de códigos alfanuméricos sin duplicados para miles de participantes
- Email marketing, SMS y WhatsApp para comunicar premios y reactivar clientes
- Dashboard con métricas de participación, empleados y terminales en directo
- Control total del dato — nada de SaaS de terceros
La arquitectura que elegí
┌──────────────────────────────────────────────────┐
│ App Móvil (React Native 0.81 + Expo SDK 54) │
│ 5 pantallas · Modo kiosko · i18n · Autostart │
│ Puerta admin oculta (7 toques + PIN) │
└──────────────────┬───────────────────────────────┘
│ HTTPS / JWT Terminal
▼
┌──────────────────────────────────────────────────┐
│ Frontend Web (React 19.2 + Vite 7.3) │
│ 30 páginas · 42 componentes · 12 modales │
│ 24 servicios API · TypeScript strict mode │
└──────────────────┬───────────────────────────────┘
│ HTTPS / JWT Bearer
▼
┌──────────────────────────────────────────────────┐
│ Backend (NestJS 11 + TypeORM 0.3) │
│ 25 módulos · 155 endpoints REST │
│ RBAC: 5 roles + 31 permisos + IDOR protection │
│ WebSocket Gateway (Socket.io) │
│ Email marketing + SMS/WhatsApp (Twilio) │
│ Talleres + Ludoteca + Club Infantil │
│ Reportes PDF (PDFKit) + Audit logs │
└──────────────────┬───────────────────────────────┘
│ TypeORM
▼
┌──────────────────────────────────────────────────┐
│ PostgreSQL 17 · 29 entidades · 74 migraciones │
│ UUID PKs · JSONB · Soft delete · Row-level MT │
└──────────────────────────────────────────────────┘
Por qué NestJS y no Express
Express es flexible, pero en un SaaS con 155 endpoints y 25 módulos necesitas estructura. NestJS me dio:
- Módulos: cada funcionalidad (auth, campañas, newsletters, sms, talleres, ludoteca) completamente aislada
- Guards y decoradores: el
TenantContextGuardinyecta el tenant en cada request sin tocar los controllers - Pipes: validación automática con
class-validatoren cada DTO - Swagger: documentación auto-generada de los 155 endpoints en
/api/docs
Con Express habría terminado con un app.js de 5.000 líneas. NestJS impone convenciones que escalan.
Multi-tenancy: aislamiento a nivel de fila
No creé una base de datos por tenant (caro y complejo). Añadí tenantId como FK en todas las tablas y creé un guard que lo inyecta automáticamente:
@Injectable()
export class TenantContextGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
request.tenantId = user.tenantId;
return true;
}
}
Cada query filtra por tenant sin que el desarrollador tenga que acordarse. Cero posibilidad de data leak entre clientes.
RBAC con 5 roles y 31 permisos granulares
No usé librerías de permisos externas. Definí 31 permisos y un decorador @RequirePermission():
| Rol | Scope | Descripción |
|---|---|---|
SUPERADMIN | Global | Control total del sistema, todos los tenants |
ADMIN | Global | Administración general |
GERENTE | Un tenant | Gestión de su centro comercial |
USER | Un tenant | Acceso básico |
TERMINAL | Un tenant | Token de tablet/kiosco |
Permisos como USERS_MANAGE, CAMPAIGNS_VIEW, TERMINALS_MANAGE, AUDIT_LOGS_VIEW se asignan por rol. El guard verifica en cada endpoint. Si no tienes permiso, 403. Sin excepciones.
Además, implementé protección IDOR: validación @IsUUID() en todos los DTOs. Si un GERENTE intenta acceder a datos de otro tenant, se registra [IDOR BLOCKED] en los audit logs con IP y userAgent, y recibe 403.
Los 3 componentes del sistema
1. Panel Web de Administración
SPA con React 19.2 + TypeScript strict + Vite 7.3 con 30 páginas y 42 componentes:
- Wizard de 3 pasos para crear campañas (datos, reglas, diseño visual)
- Constructor de formularios dinámicos con JSON Schema (5+ tipos de campo)
- Gestión de terminales: ver cuáles están online, asignar campañas, espejo en tiempo real
- Módulo de newsletters: plantillas HTML personalizables, envío masivo, tracking aperturas/clics
- Campañas SMS y WhatsApp vía Twilio con estadísticas y webhooks de entrega
- Segmentación dinámica de clientes por criterios personalizados + sistema de tags
- Gestión de empleados con ranking de captación y estadísticas por campaña
- Talleres infantiles: inscripciones, lista de espera, check-in y notificaciones
- Ludoteca: registros de entrada/salida y estadísticas de aforo
- Reportes PDF generados en servidor por campaña y por tenant
- Audit logs filtrables con exportación XLSX
2. App para Tablets / Kioscos (Android)
React Native 0.81.5 + Expo SDK 54, 5 pantallas (Setup, Idle, Home, Form, Result):
- Modo kiosko real: pantalla siempre encendida, barra de navegación oculta
- Autostart en boot: arranca automáticamente al encender el dispositivo
- Salvapantallas animado que atrae al cliente; al tocar muestra el formulario
- Teclado personalizado y selector de idioma (i18n por campaña)
- Resultado instantáneo: si el cliente ha ganado o no, con el código o mensaje
- Vuelta automática al salvapantallas tras 30s de inactividad
- Puerta de admin oculta: 7 toques en esquina + PIN para acceder al menú de configuración
- Comandos remotos vía WebSocket: cambio de campaña, reinicio, captura de pantalla
3. API Backend (NestJS)
25 módulos, 155 endpoints REST documentados con Swagger:
| Módulo | Endpoints | Descripción |
|---|---|---|
| Campañas | 27 | CRUD + códigos + momentos ganadores + horarios + salvapantallas |
| SMS/WhatsApp | 12 | Twilio + envíos + webhooks + balance + estadísticas |
| Talleres | 12 | Inscripciones + check-in + lista espera + notificaciones |
| Newsletters | 9 | CRUD + envío + tracking apertura/click |
| Tenants | 11 | CRUD + Club Infantil (hijos + config) |
| Empleados | 9 | CRUD + ranking + stats + participaciones |
| Participantes | 11 | CRUD + perfil + tags + verificación email |
| Segmentos | 7 | CRUD + participantes + count |
| Tags | 8 | CRUD + assign + participante-tags |
Problemas reales que encontré (y cómo los resolví)
1. Generación de códigos sin colisiones
Necesitaba generar hasta 1.000 códigos alfanuméricos (formato ABC-DEFG) por campaña sin duplicados, incluso con peticiones concurrentes.
Solución: algoritmo de generación con validación de unicidad en base de datos. Si hay colisión, regenera automáticamente. En producción: 0 duplicados en 15.000+ códigos generados.
2. Wizard de 3 pasos con formularios dinámicos
Las campañas se crean con un wizard (datos → reglas → diseño). Los formularios son dinámicos: el tenant configura qué campos quiere (texto, número, select, fecha, euros) usando JSON Schema.
Solución: jsonb en PostgreSQL para almacenar la configuración del formulario. React Context para mantener el estado del wizard entre pasos con persistencia, y renderizado dinámico de campos según el schema.
3. WebSockets para tablets en punto de venta
Las tablets necesitan recibir comandos remotos (cambiar campaña, reiniciar, capturar pantalla) y enviar participaciones en tiempo real.
Solución: Socket.io Gateway en NestJS con autenticación JWT de terminal. Cada tablet se registra con su token y queda vinculada al tenant. El dashboard muestra conexiones activas y permite enviar comandos remotos.
@WebSocketGateway({ cors: true, namespace: '/terminales' })
export class TerminalGateway {
@SubscribeMessage('register')
handleRegister(client: Socket, payload: { token: string }) {
// Verificar JWT terminal, vincular al tenant, almacenar conexión
}
sendCommand(tenantId: string, terminalId: string, command: RemoteCommand) {
// Enviar comando a terminal específica del tenant
this.server.to(`terminal:${terminalId}`).emit('command', command);
}
}
4. App kiosko que no se cierra
La app necesitaba arrancar automáticamente al encender el dispositivo, mantener la pantalla siempre encendida y no mostrar la barra de navegación.
Solución: expo-keep-awake para pantalla permanente, withBootCompletedReceiver para autostart en boot Android, y expo-navigation-bar para ocultar la barra de navegación. Modo kiosko real sin root.
5. Email marketing con tracking sin dependencias externas
El sistema de newsletters necesitaba saber qué emails se abrían y qué enlaces se clicaban, sin pagar por servicios externos como Mailchimp.
Solución: pixel de tracking 1x1 incrustado en cada email con token único. Al cargarse, el backend registra la apertura. Para los clicks, todos los enlaces pasan por un redirect controller que anota el evento antes de redirigir al destino.
6. SMS y WhatsApp con Twilio
Los negocios necesitaban comunicarse con sus clientes por SMS y WhatsApp para informar de premios, recordatorios y campañas.
Solución: integración con Twilio en un módulo NestJS dedicado (12 endpoints). Soporte para envíos individuales y masivos con estadísticas. Webhooks de Twilio procesados con validación de firma para confirmar el estado de entrega de cada mensaje.
7. Migraciones en producción sin downtime
Con 74 migraciones acumuladas y múltiples entidades relacionadas, gestionar el schema en producción era crítico.
Solución: dataSource.runMigrations() se ejecuta automáticamente al arrancar la API en producción. En desarrollo, TypeORM usa synchronize: true. Cada migración está versionada con timestamp. 0 errores de schema en 2 meses de producción.
Despliegue en producción
Despliegue real en atrapaclientes.es:
- VPS Ubuntu 24.04 con Docker Compose (3 contenedores: API NestJS, SPA Nginx, PostgreSQL 17)
- Coolify v4 para auto-deploy desde GitHub push
- Traefik v3.6 como proxy inverso con SSL automático (Let’s Encrypt)
- Nginx sirviendo la SPA con caché y compresión gzip
- Volúmenes persistentes para imágenes (salvapantallas, logos, fondos) y datos de PostgreSQL
- Migraciones automáticas al arrancar la API
# docker-compose.prod.yml simplificado
services:
api:
build: ./Dockerfile.api # Node 22 Alpine + pnpm + NestJS
environment:
- DATABASE_URL=postgresql://...
- JWT_SECRET=...
- TWILIO_ACCOUNT_SID=...
depends_on:
- db
web:
build: ./Dockerfile.web # Multi-stage: Build + Nginx Alpine
ports:
- "80:80"
db:
image: postgres:17-alpine
volumes:
- pgdata:/var/lib/postgresql/data
Números del proyecto
| Métrica | Valor |
|---|---|
| Endpoints API | 155 |
| Módulos NestJS | 25 |
| Migraciones TypeORM | 74 |
| Entidades de BD | 29 |
| Páginas React | 30 |
| Componentes | 42 |
| Modales | 12 |
| Hooks (custom + React Query) | 21 |
| Servicios API | 24 |
| Interfaces TypeScript | 45+ |
| Tests | 63 |
| Roles RBAC | 5 |
| Permisos granulares | 31 |
| Tiempo de desarrollo | 2 meses |
| Lighthouse Performance | 88/100 |
| Accesibilidad | 92/100 |
Lo que aprendí
- NestJS merece la pena en proyectos medianos-grandes. 25 módulos bien delimitados son mantenibles; un Express sin estructura no lo es.
- Multi-tenancy a nivel de fila funciona muy bien para SaaS donde los tenants comparten schema. El
TenantContextGuardelimina el riesgo de data leak. - IDOR protection es obligatorio desde el día 1. Registrar los intentos bloqueados en audit logs con IP da información valiosa sobre ataques.
- 74 migraciones con ejecución automática —
synchronize: truesolo en desarrollo. En producción, cada cambio de schema es una migración versionada. - Monorepo con Turborepo y pnpm — compartir tipos entre API, web y mobile elimina bugs de tipado entre capas.
- Coolify > PaaS caros — para proyectos propios, un VPS con Coolify da control total por una fracción del coste de Railway o Heroku.
- Twilio para SMS/WhatsApp — la integración es directa, los webhooks de estado de entrega son fiables y el balance se puede consultar via API.
¿Necesitas algo similar?
Si tu empresa necesita una aplicación SaaS, un sistema con WebSockets, integración de SMS/WhatsApp, email marketing o cualquier proyecto full-stack con arquitectura real, hablemos.
Otros casos reales: