How to build a member portal with login and a dashboard in Webflow Cloud

Learn how to build a member portal on Webflow Cloud where every query is scoped to the signed-in member.

How to build a member portal with login and a dashboard in Webflow Cloud

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

You can architect a member portal that keeps every user's data strictly to themselves, even when they look for someone else's.

Adding login to a Webflow Cloud app is a solved problem, and we have written it up more than once with Clerk, Auth0 and Supabase. However, another failure point is the portal.

A portal is a set of pages where every query has to answer "whose data is this?" correctly, every time, including when somebody changes a number in the URL. Authentication settles identity and then stops, which leaves the harder question entirely to your query layer.

This guide takes identity as given and builds the portal: a data model with ownership baked in, a data layer that cannot be called without a session, a dashboard that reads only the signed-in member's rows, and profile editing that cannot be pointed at somebody else.

What do you need to build a member portal in Webflow Cloud?

You need an identity provider you have already wired up, a database binding for member data, and a clear decision about which of the three places available to you holds each piece of information.

Start from these:

  • An identity provider already working in your app. Any of them will do here, and we have full setups for Clerk, Auth0 roles and Supabase Auth
  • A SQLite database binding on your Webflow Cloud environment, declared in a committed wrangler.json so the platform provisions it at deploy
  • A Webflow Cloud app on Next.js 15 or higher, with Node.js 22 or later locally
  • One sentence describing what a member should see when they sign in, because a dashboard with no answer to that becomes a navigation menu

Before the schema, settle where things live.

Data Where it belongs Why
Email, password, session Your identity provider Never store credentials yourself; the provider owns them
Display name, avatar, preferences Your app database Yours to query and join, and editable by the member
Plan or role Identity provider claim, mirrored in your database Read it from the token for gating, store it for querying
Member-owned records Your app database, with an owner column Every row needs an owner, or you cannot scope a query
Marketing pages and help content Webflow CMS Editors change it without touching the app
Data → Where it belongs → Why
Email, password, session
Your identity provider
Never store credentials yourself; the provider owns them
Display name, avatar, preferences
Your app database
Yours to query and join, and editable by the member
Plan or role
Identity provider claim, mirrored in your database
Read it from the token for gating, store it for querying
Member-owned records
Your app database, with an owner column
Every row needs an owner, or you cannot scope a query
Marketing pages and help content
Webflow CMS
Editors change it without touching the app

The fourth row matters most. A member-owned record without an owner column is unscopeable, and retrofitting ownership onto a table that already has rows in it means guessing who owned what.

With those settled, the build hinges on one rule applied without exception: the member id comes from the session, never from the request.

5 steps to build a member portal with login and a dashboard

The build is a session boundary, a schema with ownership, a data layer nothing bypasses, a server-rendered dashboard, and profile editing scoped to the caller.

Each step removes a way to read somebody else's data accidentally, so the order is deliberate rather than conventional.

1. Put one function between your app and your identity provider

Whichever provider you choose, wrap it in a single function that returns the current member or nothing. Everything else in the portal calls that, and never the provider directly.

This is worth doing even if you are certain about your provider. It gives you one place to answer "who is asking?", one place to change if you migrate, and one thing to stub in tests. More usefully, it means the ownership rule below has exactly one source of truth for the member id.

The shape is small: read the session or token the provider gives you, verify it, and return a normalized object with an id, an email, an optional displayName, and whatever claim you gate on. Return null rather than throwing when there is no session, so callers can redirect instead of catching.

Use the provider's user identifier as your id. It is stable, already unique, and saves you from maintaining a second identity table whose only job is mapping one ID to another. You finish this step with a getSessionUser() that you can call from any Server Component or Route Handler.

2. Give every member-owned table an owner column

Now the schema, and the rule that shapes it: any table holding member data carries a column naming the member it belongs to.

Declare the binding in a committed wrangler.json, pointing migrations_dir at your migrations folder:

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "member-portal",
  "compatibility_date": "2025-04-15",
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "portal",
      "database_id": "placeholder",
      "migrations_dir": "migrations"
    }
  ]
}

The migrations_dir property is what makes the next part work: Webflow Cloud applies the migrations in that folder automatically when you deploy. Leave it out, and you get a provisioned database with no tables, which fails later and looks like a query bug.

Then write the migration:

-- migrations/0001_member_portal.sql
CREATE TABLE members (
  id           TEXT PRIMARY KEY,   -- your provider's user id (the `sub`)
  email        TEXT NOT NULL,
  display_name TEXT,
  plan         TEXT NOT NULL DEFAULT 'free',
  created_at   TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE documents (
  id         TEXT PRIMARY KEY,
  -- The owner column is the whole point. A row without one cannot be
  -- scoped to a member, and every query below depends on it.
  member_id  TEXT NOT NULL REFERENCES members(id),
  title      TEXT NOT NULL,
  body       TEXT,
  -- A DEFAULT is evaluated on INSERT only, and SQLite has no
  -- ON UPDATE clause, so every write has to set this itself or the
  -- dashboard's sort order silently becomes creation order.
  updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Index the owner, because every read filters on it.
CREATE INDEX documents_member_idx ON documents (member_id, updated_at);

Two things about the Webflow Cloud side. The platform provisions the database and substitutes the real database_id at deploy, so a placeholder in your config is fine, and the binding name has to match your code.

Also, Webflow Cloud validates wrangler.json before reading bindings, and a validation failure doesn't fail the build: it logs and deploys your app without bindings, so a green deploy can still leave you with an undefined env.DB at runtime.

The foreign key on documents.member_id is doing real work beyond tidiness, because Webflow Cloud's SQLite enforces foreign key constraints by default rather than requiring you to switch them on. An orphaned row becomes a database error instead of a record nobody can reach.

One caveat on updated_at, since the dashboard orders by it: pick one format and stick to it. CURRENT_TIMESTAMP writes YYYY-MM-DD HH:MM:SS, which sorts correctly as text, and mixing app-written ISO-8601 values with a T separator into the same column breaks that ordering in a way that looks like a caching bug. You finish this step with a schema where every row's owner is a column, not an assumption.

3. Build a data layer that cannot be called without a session

This step turns the ownership rule from a convention into something the code enforces. Every read and write of member data goes through one module, and that module fetches the session itself rather than accepting a member ID.

The signature is the security property. A function taking memberId as an argument can be called with any member id by any caller, whereas a function that reads the session itself cannot.

Here is that module:

// lib/member-data.ts
import { getCloudflareContext } from '@opennextjs/cloudflare'
import { getSessionUser } from '@/lib/session'

// The only entry point to member data. Nothing else in the app is
// allowed to touch the documents table, which is what makes the
// ownership rule enforceable rather than aspirational.
async function db() {
  const { env } = getCloudflareContext()
  return env.DB
}

export async function listMyDocuments() {
  const user = await getSessionUser()
  if (!user) throw new Error('Not signed in')

  const { results } = await (await db())
    .prepare(
      `SELECT id, title, updated_at
       FROM documents
       WHERE member_id = ?
       ORDER BY updated_at DESC`
    )
    .bind(user.id)
    .all<{ id: string; title: string; updated_at: string }>()

  return results
}

export async function getMyDocument(documentId: string) {
  const user = await getSessionUser()
  if (!user) throw new Error('Not signed in')

  // Note the two conditions. Filtering on id alone is the bug this
  // whole article exists to prevent: it returns another member's
  // document to anyone who edits the URL.
  return (await db())
    .prepare(
      `SELECT id, title, body, updated_at
       FROM documents
       WHERE id = ? AND member_id = ?`
    )
    .bind(documentId, user.id)
    .first<{
      id: string
      title: string
      body: string | null
      updated_at: string
    }>()
}

Look closely at getMyDocument. The WHERE clause filters on both the document id and the member id, and that second condition is the entire defense against the most common portal bug. Query by id alone and the page works perfectly for every member, right up until one of them edits the URL and reads somebody else's document.

No error, no warning, and nothing in a normal test suite catches it, because every test assumes a member is reading their own data.

Notice too that a missing row and a row belonging to somebody else are indistinguishable from outside. That is intentional: returning "not found" rather than "not yours" avoids confirming that a given id exists at all.

You finish this step with a module where writing an unscoped query would look obviously wrong next to its related functions.

4. Render the dashboard on the server

With the data layer in place, the dashboard is mostly layout. The one decision that matters is where the gate lives.

Gate on the server, before anything renders:

// app/(portal)/dashboard/page.tsx
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getSessionUser } from '@/lib/session'
import { listMyDocuments } from '@/lib/member-data'

export default async function DashboardPage() {
  const user = await getSessionUser()

  // Gate on the server. A client-side redirect renders the page
  // first, which means the data has already left the building.
  if (!user) redirect('/login')

  const documents = await listMyDocuments()

  return (
    <main>
      <h1>Welcome back, {user.displayName ?? user.email}</h1>

      {documents.length === 0 ? (
        <p>Nothing here yet. Create your first document to get started.</p>
      ) : (
        <ul>
          {documents.map((doc) => (
            <li key={doc.id}>
              {/* next/link, not a plain anchor. Next.js applies the
                  mount path to Link, useRouter and redirect; a bare
                  href resolves against the site root and 404s. */}
              <Link href={`/dashboard/documents/${doc.id}`}>{doc.title}</Link>
            </li>
          ))}
        </ul>
      )}
    </main>
  )
}

A client-side check is not a gate. It renders the page, fetches the data, and then hides it, which means an unauthenticated visitor has already received the records by the time the interface pretends they cannot see them. Redirecting from a Server Component means nothing is sent.

The empty state deserves more thought than it usually gets. A new member arrives at a dashboard with nothing in it, and that first screen decides whether they understand what the portal is for. An empty list with one clear next action beats an empty list every time.

If you add client-side calls to your own API routes from these pages, remember they need the app's mount path: Webflow Cloud serves your app under it, Next.js applies it to route definitions, and manual fetches have to include it themselves.

You finish this step with a dashboard that shows the signed-in member their own records and redirects everyone else.

5. Let members edit their own profile, and only their own

Profile editing is where the ownership rule gets tested, because it is the first place a member sends you data about a member.

The handler below takes a display name and nothing else:

// app/api/profile/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { getCloudflareContext } from '@opennextjs/cloudflare'
import { getSessionUser } from '@/lib/session'

export async function PATCH(request: NextRequest) {
  const user = await getSessionUser()
  if (!user) {
    return NextResponse.json({ error: 'Not signed in' }, { status: 401 })
  }

  const { displayName } = (await request.json()) as { displayName?: string }
  const trimmed = (displayName ?? '').trim()

  if (!trimmed || trimmed.length > 80) {
    return NextResponse.json({ error: 'Invalid name' }, { status: 422 })
  }

  const { env } = getCloudflareContext()

  // The id comes from the session, never from the request body. If a
  // member could name the row they are updating, they could update
  // anyone's.
  const { meta } = await env.DB
    .prepare('UPDATE members SET display_name = ? WHERE id = ?')
    .bind(trimmed, user.id)
    .run()

  // An update that matched nothing is not a success. This is usually
  // a member row that was never created on first sign-in.
  if (meta.changes === 0) {
    return NextResponse.json({ error: 'No member record' }, { status: 404 })
  }

  return NextResponse.json({ ok: true })
}

The important line is the one that is missing: nowhere does this read a member id from the request body. That is the difference between an endpoint that updates your profile and an endpoint that updates anybody's.

It's easy to get wrong, because accepting an ID feels more flexible, and that flexibility is precisely the vulnerability.

Validate on the server even when the form already validates. The form is a convenience for honest users; the handler is what actually holds. And keep the allowed field list narrow: accepting an arbitrary object and spreading it into an update statement lets a member set columns you never meant to expose, such as their own plan.

You finish this step with a portal where a member can change what they should be able to change, and where the endpoints refuse to be pointed at anyone else.

What causes member portals to fail on Webflow Cloud?

Portal failures split cleanly in two: the loud ones where nobody can log in, and the quiet ones where everybody can log in and sees the wrong thing. The quiet ones are the dangerous half, so the first entry below is the one to read even if your portal seems fine.

A member can see another member's records

Cause: A query filtered on a record ID without also filtering on the owner. This is rarely a missing auth check; the member is genuinely signed in, the session is valid, and the page is behind a working gate. The gate answers whether they may see a document and says nothing about which.

Fix: Grep your data layer for queries against member-owned tables, and confirm each carries an owner condition. Then test properly: sign in as member A, take a record ID belonging to member B, and request it directly.

Normal testing never finds this, because you naturally test as a member looking at their own data. If your database supports row-level policies, add them as a second line of defense rather than a substitute for the query condition.

Note that Webflow Cloud's SQLite does not offer them, so on that stack the query condition is the only enforcement you have, which is exactly why it belongs behind a data layer nothing bypasses.

Everyone is signed out immediately after deploying

Cause: Cookie attributes that worked locally and do not hold in production. Common causes include a cookie set without Secure on an HTTPS origin, a SameSite value that blocks the callback redirect, or a cookie scoped to a path that doesn't include your app's mount path.

Fix: Inspect the response headers on the callback in the network tab and read the Set-Cookie you are actually sending rather than the one you meant to. Get the path rule direction right, because it inverts easily: Path=/ is the most permissive scope and matches every request path, including the mount path.

What breaks a portal is a cookie scoped narrower than the pages that need it, such as one set with Path=/api/auth that is then simply absent on your dashboard routes.

The Webflow Cloud-specific check is the provider's allowed callback URLs, which have to include the deployed URL with the mount path rather than only the local one.

A callback not on the list fails before a cookie is set, which looks identical from the outside.

The dashboard throws because the database binding is undefined

Cause: Either getCloudflareContext() was called at module scope rather than inside a request, or the binding never got provisioned. Both produce the same undefined value and the same unhelpful stack trace on the first query.

Fix: Confirm the context call happens inside the function that handles the request, as in the data layer above. If it already does, read the build log before touching the code: Webflow Cloud validates the committed wrangler.json first, and a validation failure logs the problem and then deploys your app with no bindings.

A green deploy doesn't mean the binding exists. Check the environment's Storage tab, which shows the binding and its status once provisioning has actually happened.

Profile updates return success, but nothing changes

Cause: An update that matched no rows. The most common reason is an id mismatch between your identity provider and your members table, usually because the member row was never created on first sign-in, so there is nothing to update.

Fix: Check the result metadata your database driver returns and treat zero changed rows as a failure rather than a success, since an update that affects nothing isn't the same as an update. Then close the underlying gap by creating the member row on the first authenticated request if it doesn't exist.

A portal that assumes a row exists because somebody signed in will break for every new member, which is the worst possible group to break for.

What to build next in your Webflow Cloud member portal

With ownership enforced and a dashboard showing real data, the next additions usually focus on what members can do, not what they can see.

Billing is the common one, and it changes the gating question from "who are you?" to "what have you paid for?" Our guide to authentication and payments together covers that pairing, and the tiered membership guide covers plan-based access to content.

If your portal is internal rather than customer-facing, the employee self-service portal follows the same pattern, with company data and CMS-driven announcements.

Frequently asked questions

Which auth provider should I use for a member portal?

Any provider with a working Webflow Cloud path will do, and the portal code above doesn't care. Wrap whichever you pick in a single session function so the choice stays swappable and your data layer has one source of truth for the member ID.

Do I need a database, or can members live in the CMS?

Use a database for member-owned records. The CMS is built for content editors to publish, not for rows a member reads and writes, and it gives you no way to scope a query to one person. Keep the CMS for pages and help content.

Is row-level security enough on its own?

Treat it as a second layer where you have it, and note you may not. Row-level policies are a Postgres feature, available on something like Supabase. Still, not on Webflow Cloud's SQLite, so on the stack this guide uses, the owner condition in the query is your only enforcement. Where policies do exist, they are worth adding, because they hold even when application code is wrong.

Where should the plan or role live?

Read it from the identity token when you are gating a request, and mirror it into your database when you need to query or report on it. Reading it from the token avoids a database call on every page; mirroring it means you can answer questions about your members.

Why gate in a Server Component rather than the client?

Because a client-side gate renders the page and fetches the data before hiding it, so the records have already been sent; redirecting on the server means an unauthenticated visitor receives nothing.


Last Updated
September 12, 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.