How it works Examples Docs Pricing Blog WP Blocks Install free

Blog /

Build a member portal scoped to the logged-in user

A member portal is the kind of project that almost always ends up at one of three bad places:

  1. A hand-rolled page template stitched together with shortcodes, where the editor and the developer fight over which one of them is going to break it next.
  2. An off-the-shelf membership plugin (MemberPress, Paid Memberships Pro, Restrict Content Pro) with three years of accumulated cruft, twelve add-ons, and a settings page that takes an afternoon to read.
  3. A separate SaaS hooked up via SSO, which solves the UX problem and creates a billing-plus-data-residency-plus-deprovisioning problem.

Each of those has tradeoffs the customer didn’t ask for and the team is going to relitigate every six months.

The DSGo path is shorter. WordPress already has user accounts. WordPress already has capabilities. WordPress already has a session. The bridge exposes all three. You write an iframe app that asks the bridge “who is this,” renders accordingly, and stores per-user preferences without touching auth code.

This post walks through examples/member-portal and the patterns that make it small.

What you build

A multi-section dashboard with three views, hash-routed inside a single iframe:

  1. Overview (/). The user’s display name, email, and a friendly greeting.
  2. Your posts (/posts). A list of the posts the current user has authored, with links to view each one.
  3. Preferences (/preferences). A small form for app-scoped user preferences (display density, notification frequency). Persists across sessions.

Visitors who aren’t logged in see a sign-in prompt instead. The bundle never tries to be an auth provider; WordPress already is one. The sign-in link points to wp-login.php?redirect_to=..., which is the canonical WP pattern, and works whether the site is using WordPress’s default login form, a redirect to a custom one, or any of the dozen “social login” plugins that hook the same flow.

The manifest

{
  "manifest_version": 1,
  "id": "account",
  "name": "Member portal",
  "version": "0.1.0",
  "entry": "index.html",
  "isolation": "iframe",
  "display": { "modes": ["page"], "default": "page" },
  "permissions": { "read": ["user", "posts"], "write": [] },
  "runtime": { "sandbox": "strict", "external_origins": [] }
}

Two permissions, both read-side: user for “who is this and what can they do,” posts for “let me list their authored content.” The install dialog shows a single “Read content” bucket with both pieces grouped under it. One bucket activated, no commerce, no AI, no external services. From the site owner’s perspective this is one of the most boring install dialogs the runtime can produce, which is the goal.

Detecting the visitor

Real shape from the example:

const dsgo = window.dsgo;

const user = await dsgo.user.current();

if (!user || !user.id) {
  renderSignedOut();
  return;
}

renderPortal(user);

dsgo.user.current() resolves either to null (logged out, in some session states) or an object with { id, name, slug, email, avatar_url, roles }. The defensive !user || !user.id covers both shapes. No try/catch around the call: bridge methods reject only on permission or transport errors, and your manifest already declared the user permission. If you wanted to handle transport errors explicitly, you’d wrap, but for “is the visitor logged in” the rejection paths shouldn’t happen in practice.

The roles array is what you’d consult if you wanted to gate parts of the dashboard by role rather than by capability. Most of the time, capability is what you actually want; roles are an implementation detail.

Capability gates

When you render an “Edit” link next to one of the user’s posts, you want it to disappear if the visitor lost the edit_posts capability since they logged in. Don’t roll that yourself; ask the bridge:

const canEdit = await dsgo.user.can('edit_posts');

for (const post of myPosts) {
  const li = el('li');
  li.appendChild(el('a', { href: post.link, text: post.title }));
  if (canEdit) {
    li.appendChild(el('a', { href: post.edit_link, text: 'Edit', class: 'edit' }));
  }
  list.appendChild(li);
}

dsgo.user.can runs the same capability check WordPress would run inside wp-admin. If the user’s role got demoted, the link is gone. If the user is an editor, the link is there. Cache the result in a local variable if you’ll check the same capability for a list of items; the bridge call is fast but not free.

Listing the user’s posts

dsgo.posts.list accepts a WP-REST-style query, including an author filter:

const result = await dsgo.posts.list({
  author: user.id,
  per_page: 20,
  orderby: 'date',
  order: 'desc',
});

const posts = (result && Array.isArray(result.items)) ? result.items : [];

A few things worth knowing:

  • The result has .items. Some bridge methods return a bare array; posts.list returns { items, total, total_pages }. The defensive accessor handles both shapes for forwards-compatibility, but the canonical shape is the wrapper.
  • The bridge passes the query straight through to /wp/v2/posts via URLSearchParams. WP REST accepts author as a single integer; URLSearchParams coerces correctly. If you wanted to filter by category, you’d pass categories: [17], which serializes to categories=17 and gets normalized back to an array on the WP side. The bridge doesn’t have a special filter language; whatever WP REST accepts, the bridge accepts.
  • Visibility rules are the bridge’s job. The bridge applies the same visibility rules WordPress would apply to a logged-in user with that ID. Drafts the user owns appear in their own list because WordPress lets the user see them; drafts other users own do not. You don’t write any of that filtering logic; it falls out of the capability model.

Per-user preferences

The preferences pane is where dsgo.storage.user.* earns its keep. The storage is keyed automatically by the visitor’s user ID; the app doesn’t see the key, just the values:

let stored = null;
try {
  stored = await dsgo.storage.user.get('prefs');
} catch (_) {
  // First time, no stored value yet.
}

const prefs = (stored && typeof stored === 'object' && !Array.isArray(stored))
  ? stored
  : { density: 'comfortable', notify: 'weekly' };

// (render the form with `prefs` as initial values)

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const next = {
    density: form.density.value,
    notify: form.notify.value,
  };
  await dsgo.storage.user.set('prefs', next);
});

That’s the whole persistence story. No database schema, no AJAX endpoint, no nonce handling, no “is the user still logged in” check. The bridge does the rest. storage.user.get returns the value directly (already unwrapped); storage.user.set takes any JSON-serializable value. The defensive shape check (typeof stored === 'object' && !Array.isArray(stored)) is just to guard against an old version of the app having stored something that’s no longer the right shape; if you only ever store one shape, you can drop the check.

The keying-by-user-id is invisible to your code, but it’s worth knowing how it works in case you need to think about it. A logged-out visitor reading the same key gets a session-scoped value that doesn’t survive logout. If you log in as user A, save preferences, log out, log in as user B, you see B’s preferences (or none, if B never set any). That’s the right model for “per-user preferences” and you’d have to write nothing extra to make it work.

What you didn’t have to write

  • An auth flow. WordPress already has one. Logged-out visitors see a prompt with a link to wp-login.php?redirect_to=...; everything else assumes the visitor is in.
  • A user table. WordPress already has one.
  • A capabilities system. WordPress already has one, and dsgo.user.can exposes it.
  • A session cookie. WordPress already has one.
  • A per-user preferences table. dsgo.storage.user.* is keyed by user ID transparently.
  • A “what posts can this user see” filter. The bridge applies WP’s capability rules.

The app is roughly 250 lines of JS plus a small stylesheet. It does the things membership plugins charge $99/year for. The reason it can be small is that none of the heavy lifting is happening inside the app; the bridge surfaces capabilities WordPress already offers.

What this app deliberately doesn’t do

A few things this v1 app skips, on purpose:

  • It can’t update the user’s profile. permissions.write is empty in v1. When write methods land, you’d add user.update and let the visitor change their display name from inside the app.
  • It doesn’t email the user. The contact app does that; if you want member-side email (notifications, password resets), you’d wire dsgo.email.send with a future to: 'self' recipient mode.
  • It doesn’t span multiple sites in a multisite network. Each site has its own users; this app’s scope is the current site.
  • It doesn’t handle subscription billing. Membership plugins fold this in; we don’t, because billing is its own conversation. If the site has WooCommerce Subscriptions or Restrict Content Pro for billing, this app sits alongside them and reads the user state.

Those are deliberate. v1 leaned conservative on writes because the trust conversation gets harder fast. Read-only members portals are the 80% case; write-side member features (edit profile, manage subscription) are the v2 conversation.

Deploy

git clone https://github.com/DesignSetGo/dsgo-apps.git
cd dsgo-apps/examples/member-portal
npx designsetgo apps deploy --build

Visit https://yoursite.com/apps/account/ while logged in. The portal renders. Log out and visit it again. The sign-in prompt appears. That’s the whole UX surface; the rest is whatever you put in the HTML.

Where to go next

  • The bridge cookbook for the user/storage/posts patterns side-by-side.
  • The bridge API reference for the full user.* surface, including the capability list.
  • The next post: a quote calculator that ships as a deliverable a freelancer can actually bill for.