Open source / base para verticales · 2026

multitenant-saas-base

Base multi-tenant con aislamiento garantizado por Postgres

Base en TypeScript para SaaS multi-tenant: orgs y sucursales, roles, billing, capabilities y archivos, con el aislamiento garantizado por RLS de Postgres y ocho guards estructurales.

Rol

Autor

Contexto

Open source / base para verticales

Año

2026

Enlaces

Tecnologías: TypeScriptNext.jstRPCDrizzle ORMPostgreSQLbetter-authTurborepoVitest

Resumen

multitenant-saas-base es una base en TypeScript para armar SaaS multi-tenant: identidad y sesiones, organizaciones y sucursales, roles y permisos editables por organización, billing, capabilities, feature flags, archivos, auditoría y notificaciones, todo con su enforcement ya puesto. En el modelo, organization es el cliente y team es la sucursal, y todos los tenants viven en un solo deploy pooled.

No es un producto terminado. Es el piso sobre el que se monta un vertical: el código está partido en platform/, lo que existe en cualquier SaaS multi-tenant, y modules/<nombre>, lo que se saca y se agrega de a uno. Vienen cuatro módulos de fábrica, customers, opening-hours, payments y whatsapp, como el ejemplo trabajado de cómo se agrega el quinto.

El problema

Un boilerplate genérico te da pantallas y deja lo difícil librado a la buena voluntad. En un SaaS multi-tenant, las tres cosas que se rompen son siempre las mismas.

Un aislamiento hecho con un where está a un filtro olvidado de mostrarle a un cliente los datos de otro, y nada falla mientras lo escribís. La estructura se erosiona: la separación entre lo que es base compartida y lo que es de un vertical es real el primer día y ya no existe al sexto mes, porque nada frena un import. Y los tests pueden pasar sin probar nada: un test de aislamiento corriendo como superusuario da verde porque las policies nunca se evalúan, no porque el aislamiento funcione.

Así que lo que quería de esta base no eran más features. Era que esas tres fallas fueran imposibles de commitear sin que algo se ponga rojo.

Decisiones

  • RLS de Postgres como backstop en vez de confiar en la capa de queries: el enforcement primario son funciones de query y mutation scopeadas que reciben orgId y teamId del contexto de tRPC, el único punto de inyección de tenant, y RLS agarra lo que se le escape.
  • withTenantScope abre una transacción y fija el tenant en un GUC transaction-local con set_config: el COMMIT lo resetea solo, así no filtra estado entre requests que comparten una conexión del pool, y las policies leen ese GUC.
  • Policies declarativas con pgPolicy y enableRLS en la tabla de Drizzle, para que entren en drizzle-kit generate: escribir SQL de policy a mano está prohibido, porque una policy que vive fuera del schema es una que nadie diffea.
  • Una migración con FORCE ROW LEVEL SECURITY, verificada contra el catálogo de un Postgres real: habilitar RLS no alcanza, porque el dueño de la tabla bypassea sus propias policies.
  • Los tests corren con un rol NOBYPASSRLS y el superusuario queda solo para migrar: con superusuario todo test de aislamiento pasa por vacuidad, verde sin haber probado nada. El driver es postgres.js no-edge a propósito, porque RLS necesita afinidad de sesión y un driver pooled en modo statement rompe el SET LOCAL.
  • platform/ no importa nunca de modules/, y ningún módulo importa de otro: cuando la plataforma tiene que disparar algo de un módulo declara un puerto inyectable y lo provee quien compone. dependency-cruiser enforza la dirección donde es un contrato, en los packages.
  • Ocho chequeos estructurales adentro de pnpm check:ci: los boundaries de dependency-cruiser más guards para la propia config de boundaries, la pirámide de tests, los readmes, el tamaño de archivo, la proporción de comentarios, las directivas mal ubicadas y la duplicación.
  • Los guards de tamaño y comentarios fallan contra baselines congelados en vez de contra un ideal: lo que ya estaba grande queda grandfathered pero solo puede achicar, una entrada muerta también falla, y nadie se suma a la lista sin que se vea en el diff. La deuda no puede crecer.
  • Meta-tests sobre propiedades del código y no sobre conducta: 13 meta-tests y 12 tests de convención cubren la clase de error que no rompe nada al escribirse. authz-meta nombra el procedure sin permiso declarado, rls-meta la tabla de negocio sin org_id, RLS y policy, y schema-migration-drift-meta el schema que quedó desalineado de su última snapshot.
  • tRPC v11 como único transporte, sin Server Actions: una sola superficie que autorizar, un solo lugar donde inyectar el tenant, y clientes tipados de arriba.
  • 24 ADRs en docs/adr, cada una escrita porque alguien iba a deshacer la decisión sin saber lo que costaba.

Stack

Turborepo con workspaces de pnpm sobre Node 22, Next.js 16 con React 19 y Tailwind 4 en la web, tRPC v11 como único transporte, Drizzle ORM sobre Postgres 16 con RLS declarativo, y better-auth con su plugin organization para la identidad.

El cobro tiene dos niveles: Polar cobra a las organizaciones su suscripción, MercadoPago es el checkout que el tenant le ofrece a sus clientes finales. Los archivos van a R2, WhatsApp pasa por Kapso, el mail por Resend, y el onboarding en producto son tours con driver.js con estado por usuario.

La calidad la sostienen Biome, dependency-cruiser, jscpd, sherif, lefthook, Vitest, Playwright y deepsec, todos colgados del mismo comando que corre CI.

Resultado

Un vertical que arranca acá se encuentra toda la capa de tenancy resuelta y, lo que más importa, candada: auth y sesiones, orgs y sucursales, roles y permisos editables, suscripciones, capabilities, flags, archivos, jobs, auditoría y notificaciones, con un aislamiento que no se rompe por un filtro olvidado.

Los cuatro módulos de fábrica son la receta del quinto. Agregar uno es crear modules/<nombre> en los packages que lo necesiten, declarar un puerto si la plataforma tiene que disparar algo, y dejar que los guards digan si encaja. Lo que antes era una charla en el code review ahora es un check en rojo.

Lo que falta está escrito y es honesto: el copy de la UI está hardcodeado en español y no hay i18n, el runner de jobs tiene un solo adapter de ejecución, y un sitio público por tenant es lo que le daría a @acme/domains el consumidor que hoy no tiene.