How to build exclusive creator communities with Memberstack memberships and Webflow CMS

Gate Webflow CMS content with Memberstack memberships by verifying member tokens server-side on Webflow Cloud.

How to build exclusive creator communities with Memberstack memberships and Webflow CMS

Ismail Ajagbe
Technical Author
View author profile
Ismail Ajagbe
Technical Author
View author profile
Table of contents

Memberstack handles signup, plans, and Stripe billing. What it cannot do on its own is keep a premium post out of your page source, where most Webflow creator communities quietly leak what members paid for.

Memberstack is one of the strongest membership layers in the Webflow ecosystem, and its member journey documentation says plainly that the getCurrentMember pattern hides content in the browser and is fine for user experience, but it is not a security boundary. The gap is that a static Webflow page has no server to ask.

Webflow Cloud closes that gap. Since User Accounts sunset, there is no first-party gating feature to fall back on, and the migration wave moved many membership sites onto Memberstack without changing the underlying architecture.

The split that holds up is this: Memberstack owns identity, plans, and payments, while a Webflow Cloud Route Handler on the same origin verifies the member token server-side and returns the premium body from your Webflow CMS.

This guide builds that system end to end: CMS fields deliberately never bound to an element, a Memberstack token verified at the edge, a per-post plan gate, entitlement lookups cached in the Webflow Cloud Key Value Store, and a Memberstack webhook that clears the cache so an upgrade unlocks content immediately instead of minutes later.

What do you need to build a Memberstack creator community on Webflow Cloud?

You need a Webflow site plan that permits custom code, a Webflow Cloud app on Next.js 15 or higher, a Memberstack app with at least one plan and both API keys, a Webflow site token scoped to cms:read, and a CMS collection whose premium fields are never bound to an element. Budget around forty minutes before you write application code.

Use the checklist below:

Requirement Where it comes from Why the build needs it
Paid Webflow site plan or paid Workspace plan Webflow Memberstack installs as site-wide custom code, which free plans do not allow
Webflow Cloud app on Next.js 15+ Webflow CLI Provides the Route Handler that performs the access check
Memberstack app, one plan, both API keys Memberstack dashboard Issues the member token and reports plan entitlements
Webflow site token with cms:read Site settings Lets the Route Handler read the gated CMS field
CMS collection with unbound premium fields Webflow Designer Keeps premium content out of the published HTML
Requirement → Where it comes from → Why the build needs it
Paid Webflow site plan or paid Workspace plan
Webflow
Memberstack installs as site-wide custom code, which free plans do not allow
Webflow Cloud app on Next.js 15+
Webflow CLI
Provides the Route Handler that performs the access check
Memberstack app, one plan, both API keys
Memberstack dashboard
Issues the member token and reports plan entitlements
Webflow site token with cms:read
Site settings
Lets the Route Handler read the gated CMS field
CMS collection with unbound premium fields
Webflow Designer
Keeps premium content out of the published HTML

Two limits matter. Each site allows a maximum of five tokens, and a token expires after 365 consecutive days of inactivity; any successful call resets the clock. A community serving requests daily will never let one lapse.

6 steps to gate Webflow CMS content with Memberstack on Webflow Cloud

The build splits in two. Steps 1 to 3 happen in the Webflow Designer and the two dashboards, and they establish the boundary: premium fields no element renders, plan IDs stored in the CMS, and secrets that exist only in the Webflow Cloud environment.

Steps 4 to 6 are code: a verification helper, a cached plan lookup with webhook invalidation, and the Route Handler that returns the gated body.

Work through them in order. Step 4 needs the plan IDs from step 2, and step 6 needs the field slugs from step 1.

1. Model creator content in the Webflow CMS with plan gate fields

Create a collection called Creator posts.

Give it the public fields your teaser page needs, then add two fields the published site will never render: a Plain text field named Required plans, holding a comma-separated list of the Memberstack plan IDs that unlock the post, and a Rich text field named Member body, holding the content members pay for.

Field name Field type Bound to an element? Purpose
Name Plain text Yes Post title on the teaser page
Slug Slug Yes Teaser URL and the lookup key for the API
Excerpt Plain text Yes Public preview copy
Cover image Image Yes Public thumbnail
Required plans Plain text No Comma-separated Memberstack plan IDs, for example pln_basic,pln_pro
Member body Rich text No The gated content
Field name → Field type → Bound to an element? → Purpose
Name
Plain text
Yes
Post title on the teaser page
Slug
Slug
Yes
Teaser URL and the lookup key for the API
Excerpt
Plain text
Yes
Public preview copy
Cover image
Image
Yes
Public thumbnail
Required plans
Plain text
No
Comma-separated Memberstack plan IDs, for example pln_basic,pln_pro
Member body
Rich text
No
The gated content

A comma-separated list rather than a single ID is the detail I wish someone had told me on my first tiered build. An exact match against one plan ID locks a Pro member out of Basic content, and you find out the week after launch when the support email arrives.

Listing every plan that should unlock a post keeps the hierarchy in the CMS where an editor can see it.

The security boundary of this architecture is the two No values in that table. In every project I have tested, a Webflow CMS field reaches the published HTML only when an element on a published page is bound to it.

Leave Required plans and Member body unbound, and Webflow has nothing to write, so there is nothing in the page source and nothing in the collection list markup. Those fields stay readable to anyone with a Designer or Editor seat and to any site token you issue, which is expected: the boundary is public visitors, not your own team.

That rule is stricter than it sounds, and it is where I watch people undo their own work. Binding the field to an element you then set to display: none does help, because the value is still serialized into the HTML.

Neither does dropping it into an Embed, referencing it inside a hidden Collection list, or passing it into a custom attribute. Bound anywhere on a published page means shipped to the browser.

Record the collection ID before you leave. Copy it from the collection's URL, or list your collections with the Data API as covered in the guide on Webflow CMS API. That ID becomes WEBFLOW_POSTS_COLLECTION_ID.

After this step, you have a collection where the teaser is public, the gate values and premium body exist only in the CMS and the Data API, and you have recorded the collection ID.

2. Install Memberstack on your Webflow site and record each plan ID

Paste Memberstack's script into the head of your Webflow site. In Webflow, that is Site settings, then Custom code, then the Head code field, then Save changes, then Publish.

Memberstack generates the snippet for you with your app ID already filled in:

<script data-memberstack-app="app_your_app_id"
        src="https://static.memberstack.com/scripts/v2/memberstack.js"
        type="text/javascript"></script>

That tag creates a window.$memberstackDom object carries the same methods as the @memberstack/dom npm package, and it writes the member token to localStorage under the key _ms-mid.

Because a Webflow Cloud app is served from the same origin as the site, your app can read a session a member established on a plain Webflow page. That same-origin detail is why this architecture needs no second login and no CORS configuration.

Now open Plans in the Memberstack dashboard and create your tiers. Copy the plan IDs into the Required plans field of every CMS post those tiers should unlock. A post listing pln_basic,pln_pro opens for any member with an active connection to either plan and stays closed to everyone else.

If you would rather add data attributes from inside the Designer than type them, the Memberstack app writes them onto the selected element, though the script tag still runs at runtime.

I keep these IDs in a Plain text field rather than an Option field on purpose. Option fields force a schema change every time marketing invents a tier, and I have never regretted letting the CMS hold an opaque string the Route Handler compares.

What you have after this step: Memberstack live on the Webflow site, at least one plan created, and every gated CMS post listing the plan IDs that unlock it.

3. Add the Memberstack and Webflow secrets to your Webflow Cloud environment

Local development reads these values from .env.local. Production reads them from the environment you created in Webflow Cloud: open the Deployments Dashboard for that environment, click Environment Variables, and add each one.

I set up staging first and pointed it at Memberstack test mode, because a mistake there costs nothing. For the three sensitive ones, use the optional Mark as a Secret control so the value is masked in the dashboard and in build logs.

# .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

Mark the Memberstack secret key, the endpoint secret, and the site token as secrets. Leave the two NEXT_PUBLIC_ values unflagged, since both compile into the client bundle by design and neither is sensitive.

MEMBERSTACK_APP_ID looks redundant next to the secret key, but it is what lets the Route Handler reject a token minted for somebody else's Memberstack app, a check I return to in step 4.

Webflow Cloud exposes both secret and non-secret variables to the build and the deployed app at runtime, and the environments documentation covers the per-environment separation that points staging to test-mode keys and production to live ones.

Set NEXT_PUBLIC_BASE_PATH to your mount path, for example /community. Webflow Cloud applies the mount path as the app's base path and asset prefix when it builds. Still, a client-side fetch to your own API route does not inherit that prefix, and Webflow's configuration documentation is explicit that you have to add it yourself.

Read the value from this environment variable rather than importing next.config to read basePath, because the builder generates its own platform configuration at build time and overwrites what you set. A fetch to /api/posts/x instead of /community/api/posts/x is the most common 404 in a Webflow Cloud app.

After this step, all seven values are present in both .env.local and the Webflow Cloud environment, with the three secrets masked and the mount path recorded.

4. Verify the Memberstack member token inside a Webflow Cloud Route Handler

Everything so far was configuration. This helper is where the gate lives. Create lib/memberstack.ts with two functions: one that verifies a member token and returns the member ID, one that reads the member's active plan connections.

Do not reach for @memberstack/admin. It depends on axios and jose, and its webhook helper reaches for Node's crypto module and Buffer, none of which the Workers runtime provides.

The Admin REST API over native fetch() is the compatible path, and it is less code:

// 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)
}

Four details in that file are load-bearing, and each one is a place where Memberstack behaves differently from what a developer would assume. The auth header is X-API-KEY carrying the raw secret key, not Authorization: Bearer.

The verify endpoint returns HTTP 400 for an invalid signature, an expired token, an audience mismatch, and a malformed body alike, and never 401, so branching on a 401 misclassifies every failure.

The subject claim is id, not the conventional sub, which is why drop-in JWT middleware returns undefined. And GET /members/:id answers 200 with data: null for a member that does not exist, so a !response.ok guard never fires, and you crash two lines later.

The audience check is the one I would refuse to ship without, and it is not in Memberstack's own Admin API verification guidance, which stops at expiry and server-side verification. The token carries an aud claim holding the app ID it was minted for.

Comparing that to your own MEMBERSTACK_APP_ID is what stops a perfectly valid token, issued by Memberstack for an unrelated app, from unlocking your content. Three lines, and I add them every time.

Note what the token does not carry: no plan, permission, or email claim, only id, type, iat, exp, aud, and iss. Entitlement always costs a second call, which is the problem step 5 solves.

After this step, you have a helper that turns a raw member token into a verified member ID and can list that member's active plan IDs, with no Node-only dependencies.

5. Cache plan lookups in the Key Value Store and invalidate them from a Memberstack webhook

Two Admin API calls per gated request work at 25 requests per second, but they add an extra round trip to every page view and burn budget during a launch spike. The Webflow Cloud Key Value Store removes the second call, and a Memberstack webhook keeps the cache honest.

Add a kv_namespaces entry to the wrangler.json that Webflow Cloud's builder generated for your app. Leave the rest of that file alone; it already carries the compatibility_date and the nodejs_compat flag the platform needs.

{
  "kv_namespaces": [
    { "binding": "MEMBER_PLANS", "id": "placeholder" }
  ]
}

The binding is the name you'll use in code, and the id is a placeholder the platform replaces with the real namespace ID when it provisions the resource at deploy time. Run npx wrangler types after saving, which creates or updates worker-configuration.d.ts so env.MEMBER_PLANS is typed rather than any.

Then, add 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() inside each function rather than at module scope, as Webflow Cloud's storage documentation requires. Call it at the top of the file, and the binding is not there when the module evaluates, which produces an undefined namespace and a crash on the first kv.get.

Note the split this creates: secrets come from process.env, while storage bindings come from the Cloudflare context, and mixing them up is the fastest way to spend an afternoon on nothing.

The 300-second TTL is a deliberate ceiling, and it cuts both ways. It guarantees a stale entitlement expires even if an invalidation never lands. Still, it also means a canceled or refunded member can keep reading for up to five minutes, plus up to another minute of Key Value Store propagation.

Using webhook

For high-value content, I drop it to 60 seconds and let the webhook do the real work.

The webhook is what makes upgrades feel instant. Configure it under Dev Tools, then Webhooks, point it at https://yoursite.com/community/api/memberstack/webhook, subscribe to member.plan.added, member.plan.updated, member.plan.canceled, member.updated, and member.deleted, and copy the Endpoint Secret into MEMBERSTACK_WEBHOOK_SECRET.

Memberstack delivers through Svix, so verification is HMAC-SHA256 over svix-id, svix-timestamp, and the raw body, and the helper Memberstack ships for it is Node-only.

Here is the same scheme on 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 })
}

Four things in that handler earn their place. The body is read once with request.text() and parsed only after the signature passes, because the signature covers the exact bytes Memberstack sent and re-serializing a parsed object with JSON.stringify can change whitespace and break every verification.

The endpoint secret arrives as whsec_ followed by base64, and only the base64 half is the HMAC key, so the prefix is stripped rather than split on the underscore. And compareSignatures deliberately avoids an early exit on the first differing character.

Web Crypto has no timingSafeEqual; this is not a true constant-time primitive, and a network-facing timing attack on an HMAC is close to unexploitable, but the loop costs nothing.

Payload shape

Memberstack's payload shape is not consistent across events. Member events put the member object at the top-level payload key, so the ID is at payload.id. Plan events put it at data.member.id alongside a planConnection object.

Read-only payload.id and the plan events, which are the exact events this cache depends on, hand you undefined and silently invalidate nothing.

After this step, you have entitlement lookups served from the Key Value Store with a 300-second ceiling, and a signature-verified webhook that clears a member's cached plans the moment their subscription changes.

6. Fetch the gated CMS body server-side and render the member reader

Now the two halves meet. This Route Handler verifies the token, resolves the member's plans through the cache, reads the CMS item, compares the plan gate, and returns the premium body only if the comparison passes:

// 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 is worth pausing on. api-cdn.webflow.com is the Content Delivery API, which mirrors the live read endpoints and caches responses for 300 seconds on non-enterprise plans.

Cached responses don't count against your plan's rate limits, and those limits matter: 60 requests per minute on Starter and Basic, 120 on higher tiers, counted per key. A launch email can exhaust a minute's budget in seconds on api.webflow.com, while the same post opened by a thousand members through the CDN host costs one billable read.

If you need a staged endpoint or a parameter the CDN host does not mirror, point CMS_HOST at https://api.webflow.com/v2 and respect the Retry-After header.

PRIVATE_HEADERS is the part I never omit. This response body is the product, so every reply carries Cache-Control: private, no-store and Vary: Authorization.

Without them, a proxy, a Cloudflare cache rule, or the browser's back-forward cache can hold a member's unlocked body and serve it to a request that never presented a token, quietly undoing everything the previous five steps built.

The status codes are deliberate too. A missing or unverifiable token returns 401 so the client can offer a login. An authenticated member without the right plan returns 403 and deliberately doesn't name the missing plan, because echoing plan IDs back lets any free signup map your entire catalog.

A failed entitlement lookup returns 503 rather than 401, so a Memberstack outage does not tell paying members to log in again. Filtering by slug returns a list even when it matches one item, so the handler reads items[0].

The client half is a component you drop into the app's post page, below whatever teaser markup you render from the public CMS fields.

Installing SDK

Install the SDK with npm install @memberstack/dom first, because the script tag you added to the Webflow site's head in step 2 doesn't run on Webflow Cloud app pages; your app serves those pages rather than Webflow's page renderer.

The two instances still share one session, because both read the same localStorage key on the same origin:

// 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() runs inside the effect on purpose. A client component still prerenders on the server, where there is no window for the SDK to attach to, and initializing at module scope is the quickest way to a build that succeeds and a page that throws.

getMemberCookie() is synchronous and returns the raw token despite its name, which is the accessor people hunt for and rarely find, because the SDK has no getMemberToken() method. The fetch prefixes NEXT_PUBLIC_BASE_PATH because client-side requests don't inherit the mount path.

The unlocked branch renders through dangerouslySetInnerHTML because Webflow's Rich text field stores markup as an HTML string. Rich text can contain embeds and custom code, so treat that string as trusted only to the extent you trust everyone holding an Editor seat.

I run it through a sanitizer on any project where contributors aren't staff, and I wouldn't skip that step on a community that lets members publish.

Commit and run webflow cloud deploy, then do the test that matters. I open a gated post in a private window, view the page source, and search for a sentence from the member body. It isn't there because Webflow never had an element to write it into, and the Route Handler never answered an unauthenticated request.

After this step, you have a deployed gated reader where the premium body reaches the browser only after a server-side token check and a server-side plan check, and never appears in the HTML Webflow publishes.

What causes Memberstack gated content to leak or fail on Webflow Cloud?

Four failures account for almost every broken build of this architecture: a premium CMS field bound to an element after launch, a Route Handler rejecting members who are demonstrably logged in, a Node-only Memberstack package breaking the deploy, and plan changes that take minutes to reach the reader. Only the first is silent, which is what makes it the expensive one.

Here is how to recognize each and what to change.

Premium content is still visible in view-source after the gate ships

Someone bound the field. Webflow generates a Collection page for every collection whether you designed one or not, and the culprit I find most often is a designer adding Member body to that auto-generated template to check the content looks right, then publishing.

Search your published HTML for a distinctive phrase from a gated post; if it appears, the field is bound somewhere.

Audit every published page that touches the collection: the Collection page template, any Collection list on a homepage or archive page, and any element carrying the field in a custom attribute or an Embed.

Check your *.webflow.io staging address and your custom domain, because a publish made before you unbound the field leaves that copy serving the content until you publish again.

Setting the element to display: none, wrapping it in a hidden div, or adding a data-ms-content attribute changes nothing, because the value is written into the HTML at publish time and styling is applied afterward in the browser. Unbind, republish, re-check the source.

The Route Handler returns 401 for members who are logged in

Start with the audience check. If MEMBERSTACK_APP_ID in your Webflow Cloud environment belongs to a different app than the public key initializing the browser SDK, every token verifies against Memberstack and then fails your own data.aud !== appId comparison.

Test-mode and live-mode pairs are the usual mismatch: a staging environment running pk_sb_ in the browser against a live app_ ID on the server produces exactly this symptom.

If the IDs match, log the verify endpoint's actual response body, because a 400 covers invalid signature, expired token, and malformed body alike, and the status code alone tells you nothing.

Then confirm the client is sending the header at all. getMemberCookie() returns undefined with no session, and a template string will happily send Bearer undefined, which reads as a rejection rather than a missing token.

This is also why the handler in step 6 returns 503 on a failed entitlement lookup rather than 401: I want an outage and an unauthenticated request to look different in the logs, because otherwise this is the section that sends you hunting for a key problem you don't have.

The build fails after adding @memberstack/admin

The Admin SDK is built for Node. It depends on axios and jose, and its webhook helper calls crypto.createHmac, Buffer.from, and timingSafeEqual, none of which the Workers runtime provides. The failure surfaces at build time or as a production 500 while next dev runs cleanly, because local development runs on full Node.

Remove it with npm uninstall @memberstack/admin rather than tree-shaking around it, since the bundler can pull a transitively imported Node built-in into the graph from code you never call.

The fetch() helpers in steps 4 and 5 cover verification, member lookup, and signature checking with no dependencies. Skip @memberstack/react and @memberstack/nextjs too: Memberstack's own developer reference tells you not to use either for new projects.

An upgrade does not unlock content until minutes later

This is the cache doing its job badly. I confirm the webhook arrives before I touch anything else, since Memberstack's dashboard shows delivery attempts and a 401 from your endpoint points to the signature check rather than the invalidation logic.

The usual cause is a framework or proxy parsing the body before your handler sees it, leaving you re-serializing JSON that no longer matches the signed bytes. A 500 instead of a 401 usually means the endpoint secret was pasted without its whsec_ prefix.

If deliveries are succeeding, remember the Key Value Store is eventually consistent, and a delete can take up to 60 seconds to propagate globally, so a member routed to a different region can briefly read a stale entry.

Lowering TTL_SECONDS shortens the worst case at the cost of more Admin API calls. Also check that you subscribed to member.plan.updated and not only member.plan.added, because a tier change on an existing subscription fires the former.

Next steps

If you sell access to individual posts rather than tiers, Stripe Checkout is the shorter path and composes cleanly here: record the purchased slug against the member and check it in the same Route Handler.

For a community layering authentication, a database, and payments in one app, authentication, database, and payments covers the edge-runtime constraints every added service runs into.

Start with the Webflow Memberstack integration to see what the membership layer covers out of the box, then size your app, your Key Value Store, and your request budget against the Webflow Cloud limits reference before your first launch, not after.

Frequently asked questions

Can I gate a normal Webflow page instead of a Webflow Cloud app page?

Yes. Keep the Route Handler and call it from a script on the Webflow page, since both share an origin. The gated field must still be unbound in the Designer; otherwise Webflow publishes the content into the page, and the server check becomes decorative.

Does this replace Memberstack's data attributes entirely?

No. Attributes remain the right tool for interface state: swapping login buttons, showing upgrade banners, hiding comment forms. Use them for anything a visitor seeing early would not cost you money, and use the Route Handler for the content members actually pay to read.

Do members need a second login for the Webflow Cloud app?

No. Memberstack stores the member token in localStorage under _ms-mid, and a Webflow Cloud app is served from your site's own origin, so the app reads the session the Webflow page already created. That same-origin behavior also means you need no CORS configuration.

How do I test the gate on localhost?

You cannot read a Memberstack session that a Webflow page created, because localhost is a different origin. I log in through the app's own Memberstack modal on localhost, which writes the same token, then run the app with Wrangler so the Key Value Store binding exists.

What does this cost on top of Webflow?

Memberstack starts at $29 per month billed monthly, or $25 per month billed yearly, for up to 1,000 members plus a 4% transaction fee on paid signups, on top of Stripe's own fees. Everything is free in test mode, which caps at 50 test members.


Last Updated
September 4, 2026
Category

Related articles


verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.