Memberstack se encarga del registro, los planes y la facturación a través de Stripe. Lo que no puede hacer por sí solo es evitar que una publicación premium aparezca en el código fuente de tu página, donde la mayoría de las comunidades de creadores en Webflow filtran silenciosamente aquello por lo que sus miembros han pagado.
Memberstack es una de las capas de membresía más sólidas en el ecosistema de Webflow, y su documentación sobre el recorrido del miembro indica claramente que este getCurrentMember patrón oculta el contenido en el navegador y es adecuado para la experiencia de usuario, pero no constituye una barrera de seguridad. El problema es que una página estática de Webflow no tiene un servidor al cual consultar.
Webflow Cloud soluciona ese problema. Desde que User Accounts dejó de estar disponible, no existe una función de protección nativa a la cual recurrir, y la ola de migraciones llevó a muchos sitios de membresía a usar Memberstack sin cambiar la arquitectura subyacente.
La estrategia que funciona es esta: Memberstack gestiona la identidad, los planes y los pagos, mientras que un Route Handler de Webflow Cloud en el mismo origen verifica el token del miembro en el servidor y devuelve el contenido premium desde tu CMS de Webflow.
Esta guía construye ese sistema de principio a fin: campos del CMS que deliberadamente nunca se vinculan a un elemento, un token de Memberstack verificado en el borde (edge), una protección por plan para cada publicación, consultas de derechos almacenadas en el Key Value Store de Webflow Cloud y un webhook de Memberstack que limpia la caché para que una mejora de plan desbloquee el contenido al instante en lugar de minutos después.
¿Qué necesitas para crear una comunidad de creadores con Memberstack en Webflow Cloud?
Necesitas un plan de sitio de Webflow que permita código personalizado, una aplicación de Webflow Cloud en Next.js 15 o superior, una aplicación de Memberstack con al menos un plan y ambas claves API, un token de sitio de Webflow con alcance para cms:read, y una colección del CMS cuyos campos premium nunca estén vinculados a un elemento. Reserva unos cuarenta minutos antes de empezar a escribir el código de la aplicación.
Usa la siguiente lista de verificación:
| Requisito | Origen | Por qué es necesario para la construcción |
|---|---|---|
| Plan de sitio de Webflow de pago o plan de Workspace de pago | Webflow | Memberstack se instala como código personalizado en todo el sitio, algo que los planes gratuitos no permiten |
| Aplicación de Webflow Cloud en Next.js 15+ | CLI de Webflow | Proporciona el Route Handler que realiza la verificación de acceso |
| Aplicación de Memberstack, un plan, ambas claves API | Panel de control de Memberstack | Emite el token del miembro e informa sobre los derechos del plan |
Token del sitio de Webflow con cms:read |
Configuración del sitio | Permite que el gestor de rutas lea el campo restringido del CMS |
| Colección CMS con campos premium sin vincular | Diseñador de Webflow | Mantiene el contenido premium fuera del HTML publicado |
| Requisito → Origen → Por qué lo necesita la compilación |
|---|
| Plan de sitio de Webflow de pago o plan de Workspace de pago |
| Webflow |
| Memberstack se instala como código personalizado en todo el sitio, algo que los planes gratuitos no permiten |
| Aplicación de Webflow Cloud en Next.js 15+ |
| CLI de Webflow |
| Proporciona el Route Handler que realiza la verificación de acceso |
| Aplicación de Memberstack, un plan, ambas claves API |
| Panel de control de Memberstack |
| Emite el token del miembro e informa sobre los derechos del plan |
Token del sitio de Webflow con cms:read |
| Configuración del sitio |
| Permite que el gestor de rutas lea el campo restringido del CMS |
| Colección CMS con campos premium sin vincular |
| Diseñador de Webflow |
| Mantiene el contenido premium fuera del HTML publicado |
Hay dos límites importantes. Cada sitio permite un máximo de cinco tokens, y un token caduca tras 365 días consecutivos de inactividad; cualquier llamada exitosa reinicia el contador. Una comunidad que realiza solicitudes a diario nunca dejará que uno caduque.
6 pasos para restringir contenido del CMS de Webflow con Memberstack en Webflow Cloud
La compilación se divide en dos. Los pasos 1 a 3 se realizan en el Diseñador de Webflow y en los dos paneles de control, y establecen el límite: campos premium que ningún elemento renderiza, ID de planes almacenados en el CMS y secretos que solo existen en el entorno de Webflow Cloud.
Los pasos 4 a 6 son código: un asistente de verificación, una búsqueda de planes en caché con invalidación mediante webhook y el gestor de rutas que devuelve el cuerpo restringido.
Síguelos en orden. El paso 4 necesita los ID de plan del paso 2, y el paso 6 necesita los slugs de campo del paso 1.
1. Modela el contenido del creador en el CMS de Webflow con campos de restricción de planes
Crea una colección llamada Publicaciones de creadores.
Añade los campos públicos que necesite tu página de adelanto y luego incluye dos campos que el sitio publicado nunca renderizará: un campo de texto plano llamado Planes requeridos, que contenga una lista separada por comas de los ID de plan de Memberstack que desbloquean la publicación, y un campo de texto enriquecido llamado Cuerpo para miembros, que contenga el contenido por el que pagan los miembros.
| Nombre del campo | Tipo de campo | ¿Vinculado a un elemento? | Propósito |
|---|---|---|---|
| Nombre | Texto plano | Sí | Título de la publicación en la página de adelanto |
| Slug | Slug | Sí | URL de adelanto y clave de búsqueda para la API |
| Extracto | Texto plano | Sí | Copia de vista previa pública |
| Imagen de portada | Imagen | Sí | Miniatura pública |
| Planes requeridos | Texto plano | No | IDs de planes de Memberstack separados por comas, por ejemplo pln_basic,pln_pro |
| Cuerpo del miembro | Texto enriquecido | No | El contenido restringido |
| Nombre del campo → Tipo de campo → ¿Vinculado a un elemento? → Propósito |
|---|
| Nombre |
| Texto plano |
| Sí |
| Título de la publicación en la página de adelanto |
| Slug |
| Slug |
| Sí |
| URL de adelanto y clave de búsqueda para la API |
| Extracto |
| Texto plano |
| Sí |
| Copia de vista previa pública |
| Imagen de portada |
| Imagen |
| Sí |
| Miniatura pública |
| Planes requeridos |
| Texto plano |
| No |
IDs de planes de Memberstack separados por comas, por ejemplo pln_basic,pln_pro |
| Cuerpo del miembro |
| Texto enriquecido |
| No |
| El contenido restringido |
Usar una lista separada por comas en lugar de un único ID es el detalle que me hubiera gustado saber en mi primera estructura por niveles. Una coincidencia exacta con un solo ID de plan deja a un miembro Pro fuera del contenido Básico, y te enteras la semana siguiente al lanzamiento cuando llega el correo de soporte.
Enumerar todos los planes que deberían desbloquear una publicación mantiene la jerarquía en el CMS, donde un editor puede verla.
El límite de seguridad de esta arquitectura son los dos valores "No" en esa tabla. En todos los proyectos que he probado, un campo del CMS de Webflow llega al HTML publicado solo cuando un elemento en una página publicada está vinculado a él.
Deja los campos "Planes requeridos" y "Cuerpo del miembro" sin vincular, y Webflow no tendrá nada que escribir; así, no habrá nada en el código fuente de la página ni en el marcado de la lista de colección. Esos campos siguen siendo legibles para cualquier persona con acceso de Diseñador o Editor y para cualquier token de sitio que emitas, lo cual es lo esperado: el límite son los visitantes públicos, no tu propio equipo.
Esa regla es más estricta de lo que parece, y es donde veo que la gente echa a perder su propio trabajo. Vincular el campo a un elemento que luego configuras como display: none no ayuda, porque el valor sigue serializándose en el HTML.
Tampoco sirve ponerlo en un elemento Embed, referenciarlo dentro de una lista de colección oculta o pasarlo a un atributo personalizado. Si está vinculado en cualquier parte de una página publicada, significa que se envía al navegador.
Registra el ID de la colección antes de irte. Cópialo de la URL de la colección o enumera tus colecciones con la API de datos, como se explica en la guía sobre Webflow CMS API. Ese ID se convierte en WEBFLOW_POSTS_COLLECTION_ID.
Después de este paso, tendrás una colección donde el adelanto es público, los valores de restricción y el cuerpo premium existen solo en el CMS y en la API de datos, y habrás registrado el ID de la colección.
2. Instala Memberstack en tu sitio de Webflow y registra cada ID de plan
Pega el script de Memberstack en el encabezado (head) de tu sitio de Webflow. En Webflow, eso es en Configuración del sitio, luego Código personalizado, después el campo Código del encabezado, Guardar cambios y, finalmente, Publicar.
Memberstack genera el fragmento de código para ti con tu ID de aplicación ya incluido:
<script data-memberstack-app="app_your_app_id"
src="https://static.memberstack.com/scripts/v2/memberstack.js"
type="text/javascript"></script>
Esa etiqueta crea un window.$memberstackDom objeto que tiene los mismos métodos que el @memberstack/dom paquete npm, y escribe el token del miembro en localStorage bajo la clave _ms-mid.
Como una aplicación de Webflow Cloud se sirve desde el mismo origen que el sitio, tu aplicación puede leer la sesión que un miembro estableció en una página estándar de Webflow. Ese detalle del mismo origen es la razón por la que esta arquitectura no necesita un segundo inicio de sesión ni configuración CORS.
Ahora abre Plans en el panel de control de Memberstack y crea tus niveles. Copia los ID de los planes en el campo Required plans de cada publicación del CMS que esos niveles deban desbloquear. Una lista de publicaciones se abre para cualquier miembro con una conexión activa a cualquiera de los planes y permanece cerrada para todos los demás.pln_basic,pln_pro
Si prefieres añadir atributos de datos desde el Designer en lugar de escribirlos, la aplicación de Memberstack los escribe en el elemento seleccionado, aunque la etiqueta de script se sigue ejecutando en tiempo de ejecución.
Mantengo estos ID en un campo de texto plano en lugar de un campo de opciones a propósito. Los campos de opciones fuerzan un cambio de esquema cada vez que el equipo de marketing inventa un nivel, y nunca me he arrepentido de dejar que el CMS contenga una cadena opaca que el gestor de rutas (Route Handler) compara.
Lo que tienes después de este paso: Memberstack activo en el sitio de Webflow, al menos un plan creado y cada publicación del CMS protegida listando los ID de los planes que la desbloquean.
3. Añade los secretos de Memberstack y Webflow a tu entorno de Webflow Cloud
El desarrollo local lee estos valores desde . La producción los lee desde el entorno que creaste en Webflow Cloud: abre el panel de despliegues (Deployments Dashboard) para ese entorno, haz clic en Environment Variables y añade cada uno..env.local
Primero configuré el entorno de pruebas (staging) y lo apunté al modo de prueba de Memberstack, porque un error ahí no cuesta nada. Para los tres valores sensibles, usa el control opcional Mark as a Secret para que el valor aparezca enmascarado en el panel de control y en los registros de compilación.
# .env.local
NEXT_PUBLIC_MEMBERSTACK_PUBLIC_KEY=pk_sb_xxxxxxxxxxxxxxxx
NEXT_PUBLIC_BASE_PATH=/community
MEMBERSTACK_APP_ID=app_xxxxxxxxxxxxxxxx
MEMBERSTACK_SECRET_KEY=sk_sb_xxxxxxxxxxxxxxxx
MEMBERSTACK_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxx
WEBFLOW_SITE_TOKEN=xxxxxxxxxxxxxxxx
WEBFLOW_POSTS_COLLECTION_ID=xxxxxxxxxxxxxxxx
Marca la clave secreta de Memberstack, el secreto del endpoint y el token del sitio como secretos. Deja los dos valores sin marcar, ya que ambos se compilan en el paquete del cliente por diseño y ninguno es sensible.NEXT_PUBLIC_
MEMBERSTACK_APP_IDparece redundante junto a la clave secreta, pero es lo que permite al gestor de rutas rechazar un token creado para la aplicación de Memberstack de otra persona, una comprobación a la que vuelvo en el paso 4.
Webflow Cloud expone tanto las variables secretas como las no secretas a la compilación y a la aplicación desplegada en tiempo de ejecución, y la documentación de entornos cubre la separación por entorno que apunta el staging a las claves de modo de prueba y la producción a las reales.
Establece en tu ruta de montaje, por ejemplo . Webflow Cloud aplica la ruta de montaje como la ruta base y el prefijo de activos de la aplicación cuando compila. Aun así, una del lado del cliente a tu propia ruta de API no hereda ese prefijo, y la documentación de configuración de Webflow es explícita al indicar que debes añadirlo tú mismo.NEXT_PUBLIC_BASE_PATH/communityfetch
Lee el valor de esta variable de entorno en lugar de importar para leer , porque el constructor genera su propia configuración de plataforma en tiempo de compilación y sobrescribe lo que establezcas. Una petición fetch a en lugar de a es el error 404 más común en una aplicación de Webflow Cloud.next.configbasePath/api/posts/x/community/api/posts/x
Después de este paso, los siete valores están presentes tanto en como en el entorno de Webflow Cloud, con los tres secretos enmascarados y la ruta de montaje registrada..env.local
4. Verifica el token de miembro de Memberstack dentro de un gestor de rutas de Webflow Cloud
Todo lo anterior era configuración. Este asistente es donde reside el control de acceso. Crea con dos funciones: una que verifique el token de un miembro y devuelva su ID, y otra que lea las conexiones a planes activos del miembro.lib/memberstack.ts
No intentes usar . Depende de y , y su asistente de webhooks recurre al módulo de Node y a , nada de lo cual proporciona el entorno de ejecución de Workers.@memberstack/adminaxiosjosecryptoBuffer
La API REST de administración sobre nativo es el camino compatible, y requiere menos código:fetch()
// lib/memberstack.ts
const ADMIN_BASE = 'https://admin.memberstack.com'
export async function verifyMemberToken(token: string): Promise<{ id: string } | null> {
const secretKey = process.env.MEMBERSTACK_SECRET_KEY
const appId = process.env.MEMBERSTACK_APP_ID
if (!secretKey || !appId) throw new Error('Missing Memberstack server credentials')
const response = await fetch(`${ADMIN_BASE}/members/verify-token`, {
method: 'POST',
headers: { 'X-API-KEY': secretKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
// Every token failure returns 400, never 401. Treat any non-200 as a rejection.
if (response.status !== 200) return null
const { data } = (await response.json()) as {
data: { id: string; type: string; aud: string; iss: string }
}
if (data.type !== 'member') return null
if (data.iss !== 'https://api.memberstack.com') return null
if (data.aud !== appId) return null
return { id: data.id }
}
export async function fetchMemberPlanIds(memberId: string): Promise<string[]> {
const secretKey = process.env.MEMBERSTACK_SECRET_KEY as string
const response = await fetch(`${ADMIN_BASE}/members/${memberId}`, {
headers: { 'X-API-KEY': secretKey },
})
if (!response.ok) throw new Error(`Memberstack lookup failed: ${response.status}`)
const { data } = (await response.json()) as {
data: { planConnections: { planId: string; active: boolean }[] } | null
}
// A missing member returns 200 with data: null, so response.ok is not enough.
if (!data) return []
return data.planConnections.filter((connection) => connection.active).map((c) => c.planId)
}
Cuatro detalles en ese archivo son fundamentales, y cada uno es un punto donde Memberstack se comporta de forma distinta a lo que un desarrollador asumiría. La cabecera de autenticación es llevando la clave secreta en bruto, no .X-API-KEYAuthorization: Bearer
El endpoint de verificación devuelve un HTTP 400 para una firma no válida, un token caducado, una discrepancia de audiencia y un cuerpo mal formado por igual, y nunca un 401, por lo que ramificar basándose en un 401 clasifica erróneamente cada fallo.
La declaración del sujeto es id, no la convencional sub, que es la razón por la que el middleware JWT predeterminado devuelve undefined. Y GET /members/:id responde 200 con data: null para un miembro que no existe, por lo que un !response.ok guard nunca se activa y el sistema falla dos líneas después.
La comprobación de la audiencia es la que me negaría a implementar sin ella, y no está en la propia guía de verificación de la API de administración de Memberstack, que se limita a la caducidad y la verificación del lado del servidor. El token lleva una aud declaración que contiene el ID de la aplicación para la que fue creado.
Comparar eso con tu propio MEMBERSTACK_APP_ID es lo que evita que un token perfectamente válido, emitido por Memberstack para una aplicación no relacionada, desbloquee tu contenido. Tres líneas, y las añado siempre.
Observa lo que el token no incluye: ninguna declaración de plan, permiso o correo electrónico, solo id, type, iat, exp, aud, y iss. El derecho de acceso siempre requiere una segunda llamada, que es el problema que resuelve el paso 5.
Después de este paso, tendrás un asistente que convierte un token de miembro sin procesar en un ID de miembro verificado y puede listar los ID de los planes activos de ese miembro, sin dependencias exclusivas de Node.
5. Almacena en caché las consultas de planes en el almacén de clave-valor e invalídalas desde un webhook de Memberstack
Dos llamadas a la API de administración por cada solicitud protegida funcionan a 25 solicitudes por segundo, pero añaden un viaje de ida y vuelta adicional a cada vista de página y consumen presupuesto durante un pico de lanzamiento. El almacén de clave-valor de Webflow Cloud elimina la segunda llamada, y un webhook de Memberstack mantiene la caché actualizada.
Añade una entrada kv_namespaces al wrangler.json que el constructor de Webflow Cloud generó para tu aplicación. Deja el resto de ese archivo como está; ya contiene el compatibility_date y la nodejs_compat bandera que la plataforma necesita.
{
"kv_namespaces": [
{ "binding": "MEMBER_PLANS", "id": "placeholder" }
]
}
El binding es el nombre que usarás en el código, y el id es un marcador de posición que la plataforma reemplaza con el ID de espacio de nombres real cuando aprovisiona el recurso durante el despliegue. Ejecuta npx wrangler types después de guardar, lo que crea o actualiza worker-configuration.d.ts para que env.MEMBER_PLANS esté tipado en lugar de any.
Luego, añade lib/plan-cache.ts:
// lib/plan-cache.ts
import { getCloudflareContext } from '@opennextjs/cloudflare'
import { fetchMemberPlanIds } from './memberstack'
const TTL_SECONDS = 300
export async function getMemberPlanIds(memberId: string): Promise<string[]> {
const { env } = getCloudflareContext()
const kv = env.MEMBER_PLANS
const key = `member-plans:${memberId}`
const cached = await kv.get(key)
if (cached) return JSON.parse(cached) as string[]
const planIds = await fetchMemberPlanIds(memberId)
await kv.put(key, JSON.stringify(planIds), { expirationTtl: TTL_SECONDS })
return planIds
}
export async function invalidateMemberPlans(memberId: string): Promise<void> {
const { env } = getCloudflareContext()
await env.MEMBER_PLANS.delete(`member-plans:${memberId}`)
}
Call getCloudflareContext() dentro de cada función en lugar de en el ámbito del módulo, como requiere la documentación de almacenamiento de Webflow Cloud. Si lo llamas al principio del archivo, el enlace no estará presente cuando se evalúe el módulo, lo que produce un undefined espacio de nombres y un fallo en el primer kv.get.
Observa la división que esto crea: los secretos provienen de process.env, mientras que los enlaces de almacenamiento provienen del contexto de Cloudflare, y mezclarlos es la forma más rápida de perder una tarde en nada.
El TTL de 300 segundos es un límite deliberado, y funciona en ambos sentidos. Garantiza que un derecho de acceso caducado expire incluso si nunca llega una invalidación. Aun así, también significa que un miembro cancelado o reembolsado puede seguir leyendo durante hasta cinco minutos, más hasta otro minuto de propagación en el almacén de clave-valor.
Uso de webhook
Para contenido de alto valor, lo reduzco a 60 segundos y dejo que el webhook haga el trabajo real.
El webhook es lo que hace que las actualizaciones se sientan instantáneas. Configúralo en Dev Tools, luego en Webhooks, apúntalo a https://yoursite.com/community/api/memberstack/webhook, suscríbete a member.plan.added, member.plan.updated, member.plan.canceled, member.updated, y member.deleted, y copia el secreto del endpoint en MEMBERSTACK_WEBHOOK_SECRET.
Memberstack realiza la entrega a través de Svix, por lo que la verificación es HMAC-SHA256 sobre svix-id, svix-timestamp, y el cuerpo sin procesar, y el asistente que Memberstack ofrece para esto es solo para Node.
Aquí tienes el mismo esquema en Web Crypto:
// app/api/memberstack/webhook/route.ts
export const runtime = 'edge'
import { invalidateMemberPlans } from '@/lib/plan-cache'
const TOLERANCE_SECONDS = 300
const INVALIDATING_EVENTS = new Set([
'member.plan.added',
'member.plan.updated',
'member.plan.canceled',
'member.updated',
'member.deleted',
])
function base64ToBytes(value: string): Uint8Array {
const binary = atob(value)
return Uint8Array.from(binary, (character) => character.charCodeAt(0))
}
function compareSignatures(a: string, b: string): boolean {
if (a.length !== b.length) return false
let mismatch = 0
for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i)
return mismatch === 0
}
async function isSignatureValid(rawBody: string, headers: Headers): Promise<boolean> {
const id = headers.get('svix-id')
const timestamp = headers.get('svix-timestamp')
const signatureHeader = headers.get('svix-signature')
const secret = process.env.MEMBERSTACK_WEBHOOK_SECRET
if (!id || !timestamp || !signatureHeader || !secret) return false
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
if (Number.isNaN(age) || age > TOLERANCE_SECONDS) return false
const key = await crypto.subtle.importKey(
'raw',
base64ToBytes(secret.replace(/^whsec_/, '')),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
)
const digest = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${id}.${timestamp}.${rawBody}`))
const expected = btoa(String.fromCharCode(...new Uint8Array(digest)))
return signatureHeader
.split(' ')
.some((entry) => entry.startsWith('v1,') && compareSignatures(entry.slice(3), expected))
}
export async function POST(request: Request) {
const rawBody = await request.text()
if (!(await isSignatureValid(rawBody, request.headers))) {
return new Response('Invalid signature', { status: 401 })
}
const body = JSON.parse(rawBody) as {
event: string
payload?: { id: string }
data?: { member?: { id: string } }
}
// Member events nest the member under `payload`; plan events nest it under `data.member`.
const memberId = body.data?.member?.id ?? body.payload?.id
if (memberId && INVALIDATING_EVENTS.has(body.event)) await invalidateMemberPlans(memberId)
return new Response(null, { status: 204 })
}
Cuatro cosas en ese manejador se ganan su lugar. El cuerpo se lee una vez con request.text() y se analiza solo después de que la firma pase, porque la firma cubre los bytes exactos que envió Memberstack y volver a serializar un objeto analizado con JSON.stringify puede cambiar los espacios en blanco y romper toda la verificación.
El secreto del endpoint llega como whsec_ seguido de base64, y solo la mitad base64 es la clave HMAC, por lo que el prefijo se elimina en lugar de dividirse por el guion bajo. Y compareSignatures evita deliberadamente una salida anticipada en el primer carácter diferente.
Web Crypto no tiene timingSafeEqual; esta no es una primitiva de tiempo constante real, y un ataque de temporización a un HMAC orientado a la red es casi imposible de explotar, pero el bucle no cuesta nada.
Forma de la carga útil
La forma de la carga útil de Memberstack no es consistente entre eventos. Los eventos de miembro colocan el objeto del miembro en la clave de nivel superior payload, por lo que el ID se encuentra en payload.id. Los eventos de plan lo colocan en data.member.id junto a un objeto planConnection.
El payload.id de solo lectura y los eventos de plan, que son los eventos exactos de los que depende esta caché, te entregan undefined y no invalidan nada de forma silenciosa.
Después de este paso, tendrás búsquedas de derechos servidas desde el almacén de clave-valor con un límite de 300 segundos, y un webhook con firma verificada que borra los planes cacheados de un miembro en el momento en que cambia su suscripción.
6. Obtén el cuerpo del CMS restringido desde el lado del servidor y renderiza el lector de miembros
Ahora las dos mitades se encuentran. Este manejador de rutas verifica el token, resuelve los planes del miembro a través de la caché, lee el elemento del CMS, compara el acceso al plan y devuelve el cuerpo premium solo si la comparación es exitosa:
// app/api/posts/[slug]/route.ts
export const runtime = 'edge'
import { verifyMemberToken } from '@/lib/memberstack'
import { getMemberPlanIds } from '@/lib/plan-cache'
const CMS_HOST = 'https://api-cdn.webflow.com/v2'
const PRIVATE_HEADERS = {
'Cache-Control': 'private, no-store, max-age=0',
Vary: 'Authorization',
}
type PostFields = {
name: string
slug: string
'required-plans'?: string
'member-body'?: string
}
export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const token = request.headers.get('authorization')?.replace(/^Bearer /, '')
if (!token) {
return Response.json({ error: 'not_authenticated' }, { status: 401, headers: PRIVATE_HEADERS })
}
const member = await verifyMemberToken(token)
if (!member) {
return Response.json({ error: 'not_authenticated' }, { status: 401, headers: PRIVATE_HEADERS })
}
const collectionId = process.env.WEBFLOW_POSTS_COLLECTION_ID
const cms = await fetch(
`${CMS_HOST}/collections/${collectionId}/items/live?slug=${encodeURIComponent(slug)}`,
{ headers: { Authorization: `Bearer ${process.env.WEBFLOW_SITE_TOKEN}` } },
)
if (!cms.ok) {
return Response.json({ error: 'cms_unavailable' }, { status: 502, headers: PRIVATE_HEADERS })
}
const { items } = (await cms.json()) as { items?: { fieldData: PostFields }[] }
const post = Array.isArray(items) ? items[0] : undefined
if (!post) {
return Response.json({ error: 'not_found' }, { status: 404, headers: PRIVATE_HEADERS })
}
const requiredPlans = (post.fieldData['required-plans'] ?? '')
.split(',')
.map((planId) => planId.trim())
.filter(Boolean)
if (requiredPlans.length > 0) {
let planIds: string[]
try {
planIds = await getMemberPlanIds(member.id)
} catch {
// Fail closed, but say so, and never as a 401.
return Response.json({ error: 'entitlement_unavailable' }, { status: 503, headers: PRIVATE_HEADERS })
}
if (!requiredPlans.some((planId) => planIds.includes(planId))) {
return Response.json({ error: 'plan_required' }, { status: 403, headers: PRIVATE_HEADERS })
}
}
return Response.json(
{ title: post.fieldData.name, body: post.fieldData['member-body'] ?? '' },
{ headers: PRIVATE_HEADERS },
)
}
CMS_HOST vale la pena analizarlo. api-cdn.webflow.com es la API de entrega de contenido, que refleja los puntos finales de lectura en vivo y cachea las respuestas durante 300 segundos en planes que no son empresariales.
Las respuestas cacheadas no cuentan para los límites de tasa de tu plan, y esos límites importan: 60 solicitudes por minuto en Starter y Basic, 120 en niveles superiores, contadas por clave. Un correo electrónico de lanzamiento puede agotar el presupuesto de un minuto en segundos en api.webflow.com, mientras que la misma publicación abierta por mil miembros a través del host CDN cuesta una sola lectura facturable.
Si necesitas un punto final de prueba o un parámetro que el host CDN no refleje, apunta CMS_HOST a https://api.webflow.com/v2 y respeta el encabezado Retry-After.
PRIVATE_HEADERS es la parte que nunca omito. Este cuerpo de respuesta es el producto, por lo que cada respuesta lleva Cache-Control: private, no-store y Vary: Authorization.
Sin ellos, un proxy, una regla de caché de Cloudflare o la caché de navegación del navegador pueden retener el cuerpo desbloqueado de un miembro y servirlo a una solicitud que nunca presentó un token, deshaciendo silenciosamente todo lo que construyeron los cinco pasos anteriores.
Los códigos de estado también son deliberados. Un token faltante o no verificable devuelve 401 para que el cliente pueda ofrecer un inicio de sesión. Un miembro autenticado sin el plan correcto recibe un 403 y, a propósito, no menciona el plan faltante, porque hacer eco de los ID de los planes permite que cualquier registro gratuito mapee todo tu catálogo.
Una búsqueda de derechos fallida devuelve 503 en lugar de 401, por lo que una interrupción de Memberstack no le dice a los miembros de pago que vuelvan a iniciar sesión. Filtrar por slug devuelve una lista incluso cuando coincide con un solo elemento, por lo que el manejador lee items[0].
La mitad del cliente es un componente que insertas en la página de publicación de la aplicación, debajo de cualquier marcado de adelanto que renderices desde los campos públicos del CMS.
Instalación del SDK
Instala el SDK con npm install @memberstack/dom primero, porque la etiqueta de script que agregaste al encabezado del sitio de Webflow en el paso 2 no se ejecuta en las páginas de la aplicación Webflow Cloud; tu aplicación sirve esas páginas en lugar del renderizador de páginas de Webflow.
Las dos instancias aún comparten una sesión, porque ambas leen la misma clave localStorage en el mismo origen:
// app/posts/[slug]/MemberBody.tsx
'use client'
import { useEffect, useRef, useState } from 'react'
import memberstackDOM from '@memberstack/dom'
type State =
| { status: 'loading' }
| { status: 'unlocked'; body: string }
| { status: 'login' }
| { status: 'upgrade' }
| { status: 'retry' }
export default function MemberBody({ slug }: { slug: string }) {
const [state, setState] = useState<State>({ status: 'loading' })
const memberstackRef = useRef<ReturnType<typeof memberstackDOM.init> | null>(null)
const base = process.env.NEXT_PUBLIC_BASE_PATH ?? ''
useEffect(() => {
// Init inside the effect: this component prerenders on the server, where there is no window.
const memberstack = memberstackDOM.init({
publicKey: process.env.NEXT_PUBLIC_MEMBERSTACK_PUBLIC_KEY as string,
})
memberstackRef.current = memberstack
const token = memberstack.getMemberCookie()
if (!token) {
setState({ status: 'login' })
return
}
fetch(`${base}/api/posts/${slug}`, { headers: { Authorization: `Bearer ${token}` } })
.then(async (response) => {
if (response.status === 403) return setState({ status: 'upgrade' })
if (response.status === 401) return setState({ status: 'login' })
if (!response.ok) return setState({ status: 'retry' })
const { body } = (await response.json()) as { body: string }
setState({ status: 'unlocked', body })
})
.catch(() => setState({ status: 'retry' }))
}, [slug, base])
if (state.status === 'loading') return <p>Checking your membership.</p>
if (state.status === 'retry') return <p>We could not confirm your membership. Please refresh.</p>
if (state.status === 'login') {
return (
<button onClick={() => memberstackRef.current?.openModal('LOGIN')}>
Log in to read this post
</button>
)
}
if (state.status === 'upgrade') return <a href={`${base}/pricing`}>Upgrade to unlock this post</a>
return <div dangerouslySetInnerHTML={{ __html: state.body }} />
}
memberstackDOM.init() se ejecuta dentro del efecto a propósito. Un componente de cliente aún se prerenderiza en el servidor, donde no hay window al cual el SDK pueda adjuntarse, e inicializar en el ámbito del módulo es la forma más rápida de obtener una compilación que tiene éxito y una página que falla.
getMemberCookie() es síncrono y devuelve el token sin procesar a pesar de su nombre, que es el descriptor que la gente busca y rara vez encuentra, porque el SDK no tiene un método getMemberToken(). La obtención prefija NEXT_PUBLIC_BASE_PATH porque las solicitudes del lado del cliente no heredan la ruta de montaje.
La rama desbloqueada se renderiza a través de dangerouslySetInnerHTML porque el campo de texto enriquecido de Webflow almacena el marcado como una cadena HTML. El texto enriquecido puede contener elementos incrustados y código personalizado, así que trata esa cadena como confiable solo en la medida en que confíes en todos los que tienen acceso de Editor.
Lo paso por un sanitizador en cualquier proyecto donde los colaboradores no sean parte del equipo, y no me saltaría ese paso en una comunidad que permite a sus miembros publicar.
Haz commit y ejecuta webflow cloud deploy, luego realiza la prueba que realmente importa. Abro una publicación restringida en una ventana privada, veo el código fuente de la página y busco una frase del cuerpo del miembro. No está ahí porque Webflow nunca tuvo un elemento donde escribirla, y el Route Handler nunca respondió a una solicitud no autenticada.
Después de este paso, tendrás un lector restringido desplegado donde el cuerpo premium llega al navegador solo después de una verificación de token y de plan en el lado del servidor, y nunca aparece en el HTML que publica Webflow.
¿Qué causa que el contenido restringido de Memberstack se filtre o falle en Webflow Cloud?
Cuatro fallos explican casi todas las compilaciones rotas de esta arquitectura: un campo CMS premium vinculado a un elemento después del lanzamiento, un Route Handler que rechaza a miembros que están claramente conectados, un paquete de Memberstack exclusivo para Node que rompe el despliegue, y cambios de plan que tardan minutos en llegar al lector. Solo el primero es silencioso, lo que lo convierte en el más costoso.
Aquí te explico cómo reconocer cada uno y qué cambiar.
El contenido premium sigue siendo visible en el código fuente después de activar la restricción
Alguien vinculó el campo. Webflow genera una página de Colección para cada colección, la hayas diseñado o no, y el culpable que encuentro más a menudo es un diseñador que añade el cuerpo del miembro a esa plantilla autogenerada para comprobar que el contenido se ve bien, y luego publica.
Busca en tu HTML publicado una frase distintiva de una publicación restringida; si aparece, el campo está vinculado en algún lugar.
Audita cada página publicada que toque la colección: la plantilla de la página de Colección, cualquier lista de Colección en una página de inicio o de archivo, y cualquier elemento que lleve el campo en un atributo personalizado o un elemento incrustado.
Revisa tu *.webflow.io dirección de staging y tu dominio personalizado, porque una publicación hecha antes de desvincular el campo deja esa copia sirviendo el contenido hasta que vuelvas a publicar.
Configurar el elemento en display: none, envolverlo en un div oculto o añadir un data-ms-content atributo no cambia nada, porque el valor se escribe en el HTML en el momento de la publicación y el estilo se aplica después en el navegador. Desvincula, vuelve a publicar, vuelve a comprobar el código fuente.
El Route Handler devuelve un 401 para miembros que están conectados
Empieza con la comprobación de la audiencia. Si MEMBERSTACK_APP_ID en tu entorno de Webflow Cloud pertenece a una aplicación diferente a la clave pública que inicializa el SDK del navegador, cada token se verifica contra Memberstack y luego falla en tu propia data.aud !== appId comparación.
Los pares de modo de prueba y modo en vivo son el desajuste habitual: un entorno de staging ejecutando pk_sb_ en el navegador contra un ID app_ en vivo en el servidor produce exactamente este síntoma.
Si los IDs coinciden, registra el cuerpo de respuesta real del endpoint de verificación, porque un 400 cubre tanto una firma inválida, un token expirado como un cuerpo mal formado, y el código de estado por sí solo no te dice nada.
Luego confirma que el cliente esté enviando la cabecera. getMemberCookie() devuelve undefined sin sesión, y una cadena de plantilla enviará alegremente Bearer undefined, lo que se interpreta como un rechazo en lugar de un token faltante.
Esta es también la razón por la que el manejador en el paso 6 devuelve un 503 en una búsqueda de derechos fallida en lugar de un 401: quiero que una interrupción y una solicitud no autenticada se vean diferentes en los registros, porque de lo contrario esta es la sección que te envía a buscar un problema de claves que no tienes.
La compilación falla después de añadir @memberstack/admin
El SDK de administración está diseñado para Node. Depende de axios y jose, y su asistente de webhooks llama a crypto.createHmac, Buffer.from, y timingSafeEqual, ninguno de los cuales está disponible en el entorno de ejecución de Workers. El fallo aparece durante la compilación o como un error 500 en producción, mientras que next dev se ejecuta sin problemas, ya que el desarrollo local utiliza Node completo.
Elimínalo con npm uninstall @memberstack/admin en lugar de intentar excluirlo mediante tree-shaking, ya que el empaquetador puede incluir en el grafo una dependencia nativa de Node importada de forma transitiva desde código que nunca llegas a ejecutar.
Los fetch() asistentes de los pasos 4 y 5 cubren la verificación, la búsqueda de miembros y la comprobación de firmas sin dependencias. Omite también @memberstack/react y @memberstack/nextjs : la propia documentación para desarrolladores de Memberstack recomienda no usar ninguno de los dos en proyectos nuevos.
Una actualización no desbloquea el contenido hasta pasados unos minutos
Esto sucede porque la caché no está haciendo bien su trabajo. Confirmo que el webhook llega antes de tocar nada más, ya que el panel de Memberstack muestra los intentos de entrega y un error 401 desde tu endpoint apunta a la comprobación de la firma y no a la lógica de invalidación.
La causa habitual es que un framework o proxy analiza el cuerpo de la petición antes de que tu manejador lo reciba, lo que te obliga a volver a serializar un JSON que ya no coincide con los bytes firmados. Un error 500 en lugar de un 401 suele significar que el secreto del endpoint se pegó sin su prefijo whsec_ .
Si las entregas se realizan correctamente, recuerda que el almacén de clave-valor tiene consistencia eventual y que una eliminación puede tardar hasta 60 segundos en propagarse globalmente, por lo que un miembro redirigido a una región diferente podría leer brevemente una entrada obsoleta.
Reducir TTL_SECONDS acorta el peor escenario a costa de realizar más llamadas a la API de administración. Comprueba también que te has suscrito a member.plan.updated y no solo a member.plan.added, ya que un cambio de nivel en una suscripción existente dispara el primero.
Próximos pasos
Si vendes acceso a publicaciones individuales en lugar de por niveles, Stripe Checkout es el camino más corto y se integra perfectamente aquí: registra el slug comprado asociado al miembro y verifícalo en el mismo manejador de rutas.
Para una comunidad que combina autenticación, base de datos y pagos en una sola aplicación, autenticación, base de datos y pagos cubre las limitaciones del entorno de ejecución en el borde (edge-runtime) con las que se topa cualquier servicio añadido.
Empieza con la integración de Webflow con Memberstack para ver qué cubre la capa de membresía de forma nativa, luego ajusta tu aplicación, tu almacén de clave-valor y tu presupuesto de peticiones según la referencia de límites de Webflow Cloud antes de tu primer lanzamiento, no después.
Preguntas frecuentes
¿Puedo restringir una página normal de Webflow en lugar de una página de aplicación de Webflow Cloud?
Sí. Mantén el manejador de rutas y llámalo desde un script en la página de Webflow, ya que ambos comparten el mismo origen. El campo restringido debe permanecer sin enlazar en el Designer; de lo contrario, Webflow publicará el contenido en la página y la comprobación del servidor será solo decorativa.
¿Esto reemplaza por completo los atributos de datos de Memberstack?
No. Los atributos siguen siendo la herramienta adecuada para el estado de la interfaz: cambiar botones de inicio de sesión, mostrar banners de actualización, ocultar formularios de comentarios. Úsalos para todo aquello que no te cueste dinero si un visitante lo ve antes de tiempo, y utiliza el manejador de rutas para el contenido por el que los miembros realmente pagan.
¿Necesitan los miembros un segundo inicio de sesión para la aplicación de Webflow Cloud?
No. Memberstack almacena el token del miembro en localStorage bajo _ms-mid, y como la aplicación de Webflow Cloud se sirve desde el mismo origen de tu sitio, la aplicación lee la sesión que la página de Webflow ya creó. Ese comportamiento de mismo origen también significa que no necesitas ninguna configuración de CORS.
¿Cómo pruebo la restricción en localhost?
No puedes leer una sesión de Memberstack creada por una página de Webflow porque localhost es un origen diferente. Yo inicio sesión a través del propio modal de Memberstack de la aplicación en localhost, que escribe el mismo token, y luego ejecuto la aplicación con Wrangler para que el enlace al almacén de clave-valor esté disponible.
¿Qué coste tiene esto además de Webflow?
Memberstack cuesta desde 29 $ al mes con facturación mensual, o 25 $ al mes con facturación anual, para un máximo de 1000 miembros, además de una comisión del 4 % por transacción en registros de pago, que se suma a las comisiones de Stripe. Todo es gratis en modo de prueba, que tiene un límite de 50 miembros de prueba.





