Cómo limitar funciones por plan en un SaaS desde el servidor
Ocultar un botón no es un muro de pago. Cómo restringir funciones y límites de uso por plan en el servidor, con entitlements de Stripe o una tabla propia y RLS.
Read in English →
Para limitar funciones por plan en un SaaS, cada petición que usa una función de pago tiene que validarse en el servidor contra el plan actual del usuario, que tu backend lee de datos que mantiene al día tu webhook de Stripe. Ocultar un botón en la interfaz es solo una comodidad: cualquiera puede llamar directo a tu API o a tu base de datos y saltárselo. Lo confiable es tener un único lugar que diga qué incluye cada plan, una función que responda "¿este usuario puede hacer esto?" y esa función llamada desde tus rutas de API y tus reglas de base de datos.
En esta guía verás por qué los muros de pago del lado del cliente se filtran, cómo modelar planes y límites, dónde aplicarlos, qué hacer con las bajadas de plan y cómo comprobar que el muro aguanta.
¿Por qué no alcanza con ocultar la función en el frontend?
En las apps hechas con Lovable, Bolt y herramientas parecidas es muy común ver if (user.plan === 'pro') mostrarBotonExportar(). El botón desaparece para los usuarios gratis, pero la acción que hay detrás sigue abierta:
- El navegador ya tiene la URL y la clave pública de Supabase, así que el usuario puede llamar directo a la tabla o a la Edge Function.
- El plan muchas veces vive en el estado de React o en
localStorage, que se edita desde las herramientas de desarrollador. - Las peticiones de red se pueden repetir desde el navegador con otro contenido.
La validación en el frontend sigue siendo útil porque da una buena experiencia: muestra la invitación a mejorar el plan en vez de un error. Lo que no puede es ser la que hace cumplir la regla.
¿De dónde debe salir la información del plan?
De tu base de datos, escrita por tu webhook, nunca del navegador. Hay dos modelos razonables.
Opción 1: Entitlements de Stripe
Stripe te deja crear features (cada una con un lookup_key único, por ejemplo exportar_pdf) y asociarlas a productos. Cuando un cliente se suscribe, Stripe crea entitlements activos para las features de ese producto y envía entitlements.active_entitlement_summary.updated cada vez que cambian, incluidas subidas, bajadas de plan y cancelaciones. Tu webhook guarda la lista de features activas de cada cliente.
Dos detalles de la documentación de Stripe: el resumen del webhook trae como máximo 10 entitlements, así que si hay más debes pedir la lista completa a la API; y cuando cambias qué features incluye un producto, las suscripciones existentes toman el cambio al inicio de su siguiente período de cobro.
Los entitlements responden "¿este cliente tiene la función X?". Los límites numéricos, como 3 proyectos o 10.000 tokens de IA al mes, los sigues definiendo tú.
Opción 2: Tu propia tabla de planes
Guarda el plan en tu fila de suscripción (a partir del lookup key del precio) y mantén una tabla pequeña con lo que permite cada plan:
| plan | max_proyectos | exportar_pdf | acceso_api |
|---|---|---|---|
| free | 1 | false | false |
| pro | 10 | true | false |
| team | 50 | true | true |
Es más simple para empezar y deja los límites junto a las funciones. Elijas lo que elijas, define todo en un solo lugar: la lógica de planes repartida en diez componentes es justo lo que hace que una pantalla termine aplicando un límite viejo. Si tus datos de suscripción todavía no son confiables, arregla eso primero con la guía de suscripciones con Stripe.
¿Dónde aplicas los límites?
En cada punto de entrada que pueda ejecutar la acción.
- Rutas de API y funciones del servidor. Agrega un único helper, por ejemplo
await requireFeature(user, 'exportar_pdf'), al principio de cada ruta de pago. Lee el plan guardado y devuelve un 403 con un mensaje claro si el usuario no califica. - Reglas de la base de datos. Si el navegador habla directo con la base, como en Supabase, pon la regla en Row Level Security para que se cumpla aunque alguien se salte tu API.
- Tareas en segundo plano y webhooks. Las tareas programadas que generan reportes o llaman a modelos de IA también deben validar; si no, una cuenta que bajó de plan sigue recibiendo trabajo pagado.
Una política de Supabase que limita cuántos proyectos puede crear un usuario podría verse así:
create policy "limite de proyectos por plan" on projects
for insert to authenticated
with check (
owner_id = auth.uid()
and (select count(*) from projects p where p.owner_id = auth.uid())
< coalesce(
(select pl.max_proyectos
from subscriptions s
join plan_limits pl on pl.plan = s.plan
where s.user_id = auth.uid()
and s.status in ('active', 'trialing')),
1) -- sin plan pago, se aplica el límite gratuito
);
Las validaciones por conteo como esta se pueden burlar con dos peticiones que llegan en el mismo instante. Para límites donde eso importa, como créditos o uso de IA, lleva un contador y actualízalo en la misma transacción que la acción. Si necesitas políticas para otros casos, revisa los ejemplos de políticas RLS en Supabase.
¿Y los límites de uso en funciones con IA?
Las funciones por petición son de sí o no; las de IA suelen ser de "cuánto". Registra el uso en tu propia tabla en el momento, revisa el saldo disponible antes de llamar al modelo y decide qué pasa al llegar al límite: bloquear, frenar o cobrar el excedente. Cobrar ese excedente lo vemos en cobro por uso con Stripe, y cómo evitar que un solo usuario dispare tus costos, en limitar el uso de funciones con IA.
¿Qué pasa cuando un cliente baja de plan?
Decídelo antes de la primera bajada, no durante.
- Conserva los datos, limita la acción. Un usuario del plan gratis con 7 proyectos conserva los 7, pero no puede crear el octavo. Borrar datos al bajar de plan genera correos furiosos.
- Deja en solo lectura lo que sobra, si mantenerlo completamente usable volvería inútil el plan pagado.
- Sincronízalo con el cobro. Si las bajadas se aplican al final del período, algo que permite el portal de clientes, tu webhook recibirá el cambio en ese momento. No quites funciones apenas hacen clic.
¿Cómo compruebas que el muro de pago aguanta?
- Inicia sesión como usuario gratis y copia, desde la pestaña Network del navegador, una petición que tu app hace para una función de pago.
- Repítela, por ejemplo con curl. Debería fallar con un 403 o un error de la base de datos, no funcionar.
- En un sandbox, sube de plan al usuario y confirma que la misma petición ahora funciona, sin cambiar código ni desplegar.
- Baja de plan y cancela, y confirma que el acceso cambia exactamente cuando dice tu política.
Respuestas rápidas
¿Una herramienta de feature flags es lo mismo? No del todo. Los flags sirven muy bien para lanzamientos graduales, pero la decisión sobre funciones pagadas igual tiene que aplicarse en el servidor a partir de los datos de cobro.
¿Consulto el plan a Stripe en cada petición? No. Es lento y agrega una dependencia en cada carga de página. Lee tu propia copia, mantenida al día por los webhooks.
¿Qué estado cuenta como pagado? Normalmente active y trialing. Decide a conciencia qué hacer con past_due; en pagos fallidos en Stripe vemos las opciones.
¿Sigo necesitando la validación en el frontend? Sí, por la experiencia de usuario. Lo que nunca debe pasar es que sea la única.
¿Quieres que alguien intente saltarse tu muro de pago?
La forma más rápida de saber si tus funciones pagadas están protegidas es que alguien intente evitarlas. Lo cubren tanto nuestro servicio de pagos con Stripe como la revisión de seguridad de Supabase. También puedes empezar con la revisión gratuita de tu app, leer la documentación de entitlements de Stripe o escribirnos.


