How to build a Memberstack member directory in Webflow

How to build a gated member directory with Memberstack and Webflow Cloud

Learn how to build a members-only directory with Memberstack and a Next.js app on Webflow Cloud.

How to build a gated member directory with Memberstack and Webflow Cloud

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

A small Webflow Cloud app that reads Memberstack profiles live keeps a gated member directory current without anyone copying data by hand.

Most membership sites hit the same wall. Login and gated pages work, then someone asks for a page showing who else belongs. A Collection List on a gated page holds up until the roster outgrows manual edits, or until a member asks why their old employer still shows. The profile data members already typed into Memberstack is the directory.

In this guide, we’ll mount a Next.js app at /directory that redirects visitors without a Memberstack session, and a Route Handler verifies each member's token and plan with Memberstack before returning any profiles. Only members who opt in appear, and email addresses never leave the server.

What do you need to build a Memberstack member directory in Webflow?

You need five things:

  • Webflow site with Memberstack: A site with Memberstack installed, a plan members join, a working login page at /login, and a gated page at /members/directory that will link to the app.
  • Memberstack secret key and plan ID: The secret key starts with sk_, or sk_sb_ in sandbox mode, and the plan ID identifies the plan that may see the directory.
  • Node.js 22, Next.js 15, and npm: Next.js 15 is the version floor Webflow Cloud supports, and npm is the only package manager it supports, per the bring your own app page.
  • Directory field list: Decide which profile fields appear. I use name, company, job title, a short bio, and an opt-in toggle; email stays out entirely.
  • A cookie-based session: Memberstack stores the session in the browser's local storage by default, which a server never sees, so step 1 switches it to a cookie.

The secret key never leaves the server, so every Memberstack call happens in a Route Handler or in the adapter it imports, and the browser only ever talks to your own /directory/api/members path.

6 steps to build a Memberstack member directory in Webflow Cloud

The build is a Next.js app with one adapter holding every Memberstack call, one Route Handler, one middleware file, and one client page. Middleware redirects visitors without a session cookie, and the Route Handler handles the actual verification.

1. Set up the directory fields and the session cookie in Memberstack

Create the custom fields the directory will read, and add an explicit opt-in field so no member appears without choosing to. In your Memberstack dashboard, add five custom fields with these IDs: name, company, job-title, bio, and show-in-directory.

Memberstack stores custom field values as strings, so the adapter treats the opt-in as on only when it reads the string true. Members set these from a profile form on the Webflow site, and I keep the IDs lowercase and hyphenated, because a field ID with a space becomes a bracket-access headache in every file that touches it.

Next, make the session reach the server. By default, Memberstack keeps the logged-in member's token in local storage, which the browser never sends with a request.

Memberstack's cookie setting moves it into a cookie named _ms-mid, which the browser does send to the app because it is mounted on the same domain. In Webflow, open the Memberstack install script in the site's custom code and add the data-memberstack-use-cookies attribute:

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

If the site runs a cookie banner, allow _ms-mid as a functional cookie, or the session disappears for anyone who declines. Publish the site, log in as a test member, and check DevTools under Application and then Cookies: an _ms-mid cookie on your domain means the session is ready for the app.

When this step is done, the test member can flip the opt-in toggle in their profile and see all five values persist in their Memberstack record.

2. Create the Next.js app and define the environment variables

Scaffold the app with npm and keep every secret in the Webflow Cloud environment rather than in a committed file. Scaffold with create-next-app@15 so the project starts on Next.js 15:

npx create-next-app@15 member-directory --typescript --app --use-npm --eslint --no-tailwind --no-src-dir --no-turbopack --import-alias "@/*"
cd member-directory

You now have an App Router project with a lockfile that Webflow Cloud can build. Do not add basePath to next.config: Webflow Cloud injects the mount path at build time, and Webflow's configuration docs say "You don't need to add an adapter, a base path, or an output mode."

Leave export const runtime = 'edge' out of every file, even though the bring-your-own-app page still tells you to add it. Webflow Cloud runs on Cloudflare Workers, an edge platform, but the Next.js edge runtime target is a separate build directive that the OpenNext Cloudflare adapter does not support.

Required environment variables

The app reads four variables:

Variable What it holds
MEMBERSTACK_SECRET_KEY The sk_ secret key from Memberstack; mark it secret and never prefix it with NEXT_PUBLIC_
MEMBERSTACK_PLAN_ID The ID of the plan whose members may see the directory
MEMBERSTACK_LOGIN_PATH The login page path on the Webflow site, /login here
NEXT_PUBLIC_BASE_PATH The mount path, /directory, set in the Webflow Cloud environment; left empty locally
Variable → What it holds
MEMBERSTACK_SECRET_KEY
The sk_ secret key from Memberstack; mark it secret and never prefix it with NEXT_PUBLIC_
MEMBERSTACK_PLAN_ID
The ID of the plan whose members may see the directory
MEMBERSTACK_LOGIN_PATH
The login page path on the Webflow site, /login here
NEXT_PUBLIC_BASE_PATH
The mount path, /directory, set in the Webflow Cloud environment; left empty locally

Set the first three in a local .env.local and leave NEXT_PUBLIC_BASE_PATH empty there, because the app runs at the root of localhost:3000 during development. Webflow Cloud does not create NEXT_PUBLIC_BASE_PATH for you, so add it to the environment yourself at deploy.

Webflow's environment variables docs state that "Both secret and non-secret environment variables are available to your application's build process and to the deployed application at runtime," so you don't need a second place to configure them for the build.

3. Write the Memberstack adapter

Put every Memberstack call in one server-only module. It verifies a session token, checks that a member holds an active connection to your plan, and returns the opted-in members in a normalized shape.

Keeping all of it here means the Route Handler never sees an endpoint URL or a raw member object, and a change on Memberstack's side is a one-file edit.

The adapter uses two endpoints from Memberstack's Admin REST API, both authenticated with the secret key in an x-api-key header. Verify token returns 200 with the member's ID for a valid token and a non-200 status for anything else. List members returns at most 100 members per page, with hasNextPage and an endCursor for the next one.

Adapter implementation

Create lib/memberstack.ts:

// lib/memberstack.ts
// Server-only. Every Memberstack-specific value lives in this file.

const API = 'https://admin.memberstack.com/members';

export type DirectoryMember = {
  id: string;
  name: string;
  company: string;
  jobTitle: string;
  bio: string;
};

const FIELD = {
  name: 'name',
  company: 'company',
  jobTitle: 'job-title',
  bio: 'bio',
  showInDirectory: 'show-in-directory',
} as const;

type RawMember = {
  id: string;
  customFields?: Record<string, string | undefined>;
  planConnections?: { planId: string; active: boolean }[];
};

function env(key: string): string {
  const value = process.env[key];
  if (!value) throw new Error(`Missing environment variable: ${key}`);
  return value;
}

function authHeaders(): Record<string, string> {
  return { 'x-api-key': env('MEMBERSTACK_SECRET_KEY'), 'Content-Type': 'application/json' };
}

export async function verifyMemberToken(token: string): Promise<string | null> {
  const res = await fetch(`${API}/verify-token`, {
    method: 'POST',
    headers: authHeaders(),
    body: JSON.stringify({ token }),
  });
  if (!res.ok) return null;
  const body = (await res.json()) as { data?: { id?: string } };
  return body.data?.id ?? null;
}

async function listAllMembers(): Promise<RawMember[]> {
  const members: RawMember[] = [];
  let after: string | undefined;
  do {
    const url = new URL(API);
    url.searchParams.set('limit', '100');
    if (after) url.searchParams.set('after', after);
    const res = await fetch(url, { headers: authHeaders() });
    if (!res.ok) throw new Error(`Memberstack responded with ${res.status}`);
    const page = (await res.json()) as {
      data?: RawMember[];
      hasNextPage?: boolean;
      endCursor?: string | number | null;
    };
    members.push(...(page.data ?? []));
    after = page.hasNextPage && page.endCursor != null ? String(page.endCursor) : undefined;
  } while (after);
  return members;
}

function onPlan(member: RawMember): boolean {
  const planId = env('MEMBERSTACK_PLAN_ID');
  return (member.planConnections ?? []).some((p) => p.planId === planId && p.active);
}

export async function getDirectory(viewerId: string): Promise<DirectoryMember[] | null> {
  const members = await listAllMembers();
  const viewer = members.find((m) => m.id === viewerId);
  if (!viewer || !onPlan(viewer)) return null;

  return members
    .filter((m) => onPlan(m) && m.customFields?.[FIELD.showInDirectory] === 'true')
    .map((m) => ({
      id: m.id,
      name: m.customFields?.[FIELD.name] ?? '',
      company: m.customFields?.[FIELD.company] ?? '',
      jobTitle: m.customFields?.[FIELD.jobTitle] ?? '',
      bio: m.customFields?.[FIELD.bio] ?? '',
    }))
    .filter((m) => m.name)
    .sort((a, b) => a.name.localeCompare(b.name));
}

A valid token is not enough on its own: a member whose plan lapsed still holds one. getDirectory returns null unless the viewer has an active connection to your plan, and it lists only members who are on that plan and opted in, so a canceled member drops out of the directory too.

The DirectoryMember type has no email field, and the mapping never copies one, so no later edit to the page can leak an address by accident.

4. Add the Route Handler that serves search results

Expose the directory at /api/members inside the app and verify the Memberstack session on every request. The server applies the search query, so the full roster never ships to the client.

Create app/api/members/route.ts:

// app/api/members/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getDirectory, verifyMemberToken } from '@/lib/memberstack';

export async function GET(request: NextRequest) {
  const token = request.cookies.get('_ms-mid')?.value;
  const memberId = token ? await verifyMemberToken(token) : null;
  if (!memberId) {
    return NextResponse.json({ error: 'Not a member' }, { status: 401 });
  }

  const members = await getDirectory(memberId);
  if (!members) {
    return NextResponse.json({ error: 'Plan required' }, { status: 403 });
  }

  const q = request.nextUrl.searchParams.get('q')?.trim().toLowerCase() ?? '';
  const results = q
    ? members.filter((m) =>
        [m.name, m.company, m.jobTitle].some((value) => value.toLowerCase().includes(q)),
      )
    : members;

  return NextResponse.json({ members: results });
}

Webflow Cloud replaces every response's Cache-Control header with private, no-cache, so a shared cache never hands one member's results to the next visitor.

With the app running locally and a real _ms-mid value in the request, GET /api/members?q=acme returns the opted-in members whose name, company, or title contains "acme". The same request without the cookie returns a 401, and a member without an active plan gets a 403.

5. Gate the app with middleware and build the search page

Redirect any browser without a session cookie to the login page before the page renders, and let the Route Handler do the real verification. Middleware on Webflow Cloud has to be the Edge kind: the framework customization docs state that "Node.js runtime middleware isn't supported. Only Edge runtime middleware works on the Workers runtime."

The cookie check goes in middleware.ts at the project root:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

export function middleware(request: NextRequest) {
  if (request.cookies.has('_ms-mid')) {
    return NextResponse.next();
  }
  const login = new URL(process.env.MEMBERSTACK_LOGIN_PATH ?? '/login', request.url);
  return NextResponse.redirect(login);
}

export const config = {
  matcher: ['/((?!_next|api).*)'],
};

The matcher excludes /api on purpose. The Route Handler already returns a 401 for a missing or invalid token, and a redirect on a JSON endpoint would hand the client page an HTML login form to parse.

Middleware is only a cheap first gate, so even a middleware bypass exposes nothing but the empty page shell; create-next-app@15 also installs a 15.5 release, which is patched against the GHSA-f82v-jwr5-mffw middleware bypass.

Directory search page implementation

The page is a client component that debounces input, prefixes every fetch with the base path, and ignores responses for a query the visitor has already changed:

// app/page.tsx
'use client';

import { useEffect, useState } from 'react';

type Member = {
  id: string;
  name: string;
  company: string;
  jobTitle: string;
  bio: string;
};

const BASE = process.env.NEXT_PUBLIC_BASE_PATH ?? '';

export default function DirectoryPage() {
  const [query, setQuery] = useState('');
  const [members, setMembers] = useState<Member[]>([]);
  const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');

  useEffect(() => {
    const controller = new AbortController();
    const handle = setTimeout(async () => {
      setStatus('loading');
      try {
        const res = await fetch(`${BASE}/api/members?q=${encodeURIComponent(query)}`, {
          credentials: 'include',
          signal: controller.signal,
        });
        if (!res.ok) {
          setStatus('error');
          return;
        }
        const data = (await res.json()) as { members: Member[] };
        setMembers(data.members);
        setStatus('ready');
      } catch (error) {
        if ((error as Error).name !== 'AbortError') setStatus('error');
      }
    }, 250);
    return () => {
      clearTimeout(handle);
      controller.abort();
    };
  }, [query]);

  return (
    <main>
      <h1>Member directory</h1>
      <input
        type="search"
        placeholder="Search by name, company, or title"
        value={query}
        onChange={(event) => setQuery(event.target.value)}
      />
      {status === 'error' && <p>Sign in with an active membership to see the directory.</p>}
      {status === 'ready' && members.length === 0 && <p>No members match.</p>}
      <ul>
        {members.map((member) => (
          <li key={member.id}>
            <strong>{member.name}</strong>
            {member.jobTitle && `, ${member.jobTitle}`}
            {member.company && ` at ${member.company}`}
            {member.bio && <p>{member.bio}</p>}
          </li>
        ))}
      </ul>
    </main>
  );
}

The BASE prefix is the part people skip. The bring-your-own-app docs are explicit that "Client-side fetch calls must manually include the base path to correctly reach your endpoints," because the mount path is applied to the app's routes at build time and not to a URL string you write inside a fetch.

Locally, the prefix is empty, so the page fetches /api/members at localhost:3000.

To test signed in, copy the _ms-mid cookie value from a logged-in browser on your Webflow site and add it to localhost:3000 in DevTools. Typing a company name then narrows the list after a quarter-second pause.

Deleting the cookie and reloading redirects to localhost:3000/login, which returns a 404 locally because the login page lives on the Webflow site; that 404 confirms the middleware ran.

6. Deploy the app and mount it at /directory

Push the app, point a Webflow Cloud environment for the site at it, set the mount path to /directory, and add the four variables to that environment. The mount path is the value Webflow Cloud injects as the base path at build time, which is why NEXT_PUBLIC_BASE_PATH gets that same string: the platform applies one to your routes, your client code reads the other, and they do not know about each other.

Mark MEMBERSTACK_SECRET_KEY as a secret so it is redacted from build logs, and leave the other three plain. Then trigger the build and publish the Webflow site so the gated /members/directory page links to /directory. The site needs Premium or higher before launch if the /directory mount should serve on your custom domain.

When the deploy finishes, a logged-in member on your plan who follows the link lands on the directory with the search box live. A logged-out browser hitting yourdomain.com/directory directly is redirected to /login, and a request to /directory/api/members without a cookie gets a 401.

What causes a Memberstack directory on Webflow Cloud to fail?

Most failures trace to the session cookie, the runtime directive, the middleware filename, or the base path, and most only appear after the app deploys. Each entry starts from what the build log or the live /directory page shows.

Every member is redirected to /login

Cause: The browser has no _ms-mid cookie, so middleware sends everyone to the login page. Memberstack keeps the session in local storage unless the install script carries data-memberstack-use-cookies, and a cookie banner that blocks functional cookies removes it again.

Fix: Add the attribute from step 1 to the install script and publish the site. Then log in again and confirm _ms-mid appears under Cookies in DevTools. If it vanishes after the cookie banner, allow it as a functional cookie in the banner's settings.

Webflow Cloud build fails on an API route

Cause: The bring-your-own-app page still instructs you to add export const runtime = 'edge' to API routes, and following it produces a build the OpenNext Cloudflare adapter cannot complete. The confusion is that "edge" names both the Workers platform and the Next.js runtime target, and the page uses both meanings.

Fix: Remove the line from every Route Handler and page, then redeploy with the runtime unset. Your handlers run on Workers either way. I keep a search for runtime = 'edge' in the pre-push checklist on Cloud projects, because a well-meaning teammate reading the docs will put it back.

Logged-out visitors are not redirected after upgrading to Next.js 16

Cause: Next.js 16 renames middleware to proxy, and the renamed proxy.ts file runs on the Node runtime with no way to opt into Edge, so it never runs on Webflow Cloud.

Fix: Keep the file named middleware.ts through the upgrade, or rename it back if it was already changed. Confirm the redirect by loading /directory in a private window after deploy, not only under next dev.

Search works locally but every request returns 404 on the live site

Cause: The client page is fetching /api/members at the site root instead of /directory/api/members. A URL string inside a fetch call is not rewritten, so the bug is invisible locally and appears at deploy. The same symptom appears when NEXT_PUBLIC_BASE_PATH is set in .env.local but missing from the Webflow Cloud environment, since the prefix then compiles to an empty string.

Fix: Set NEXT_PUBLIC_BASE_PATH to /directory in the Webflow Cloud environment, with a leading slash and no trailing slash, and rebuild. Next.js inlines NEXT_PUBLIC_ values into the client bundle when it builds, so changing it without a new build changes nothing.

Signed-in members see the sign-in message after deploy

Cause: The client page shows that message for any non-OK response. A 500 means a variable is missing from the Webflow Cloud environment, usually one that exists in .env.local and was never added there.

A 401 means Memberstack rejected the token, which happens when the secret key belongs to the other mode, such as a sandbox sk_sb_ key on a live site. A 403 means the member's plan connection is inactive or the plan ID is wrong.

Fix: Check the status of the /directory/api/members request in the network tab first. For a 500, compare variable names between .env.local and the environment.

For a 401, confirm the key's mode matches the site's Memberstack mode. For a 403, copy the plan ID again from the Memberstack dashboard and check the member's plan in their profile.

Signed-in members always see "No members match"

Cause: The adapter keeps only members whose show-in-directory value is the string true and whose plan connection is active. If the profile form writes a different value, such as on or yes, the filter drops every member.

Fix: During development, log one raw member's customFields inside getDirectory, read the stored show-in-directory value, and either match the comparison to it or change the form so it writes true. Remove the log afterward.

What you can build next with Memberstack and Webflow

I would add a per-member profile page at /directory/[id] as one more route that calls the same adapter, and an edit-your-listing route that verifies the token before it writes any profile fields, so members update their own entry without leaving the app.

For a full membership site with gated content and paid plans, the Memberstack memberships guide covers the setup this directory sits on.

For the platform side of the next route, Webflow's developer docs cover Webflow Cloud apps and the limits they run within.

Frequently asked questions

Will the directory show every member on a large roster?

Yes. Memberstack returns at most 100 members per page, so the adapter follows endCursor until hasNextPage is false. A very large roster slows each search, which the next answer covers.

Does every search call Memberstack?

Yes. Each debounced request verifies the token and then pages through the roster at 100 members per call, so a 1,000-member directory costs 11 Memberstack calls per search. Memberstack allows 25 requests per second, which covers a modest directory. For a busy one, cache the normalized list for a minute in Webflow Cloud's Key Value storage, and never cache a single member's search results where another visitor could read them.

Is there a no-code way to get a directory on a Memberstack site?

For a small roster, a CMS Collection List on a Webflow page gated by Memberstack works without an app. An editor then maintains each listing as a CMS item, instead of members editing or hiding their own entries. The app in this guide reads each member's profile and opt-in live from Memberstack instead.

Can I add more filters, such as location or industry?

Yes. Add the custom field in Memberstack, add its ID to the FIELD map and the DirectoryMember type in lib/memberstack.ts, include it in the values the Route Handler searches, and add it to the Member type and the list markup in app/page.tsx. Middleware needs no change.


Last Updated
October 19, 2026
Category

Related articles


verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com 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.