Shopify member directory — moderated profiles, map, and a key-less geocoded UI

Built for a member-based non-profit (a separate engagement from my day-to-day work). The organization name, storefront URL, store/app IDs, API tokens, worker URLs, metaobject handles, and repository names are intentionally omitted — for the client’s privacy and for security. The architecture, decisions, bugs, and fixes are real.

TL;DR

I built a public member directory for a non-profit on Shopify, in three parts: a customer-account UI extension for self-service profile editing, a serverless edge worker that turns customer-metafield edits into approved, published profiles, and a storefront theme that renders the directory and a self-contained Leaflet + OpenStreetMap map.

The interesting work wasn’t any one part — it was the moderation contract that ties them together, and the small decisions (materialize at write time, vendor your assets, derive coordinates server-side) that made the system reliable.

Context

Three moving parts:

  1. Customer-account UI extension — a “Membership” page inside the hosted customer account where members fill in a profile (bio, role, business details, social links, photo, gallery), certify eligibility, and submit an application.
  2. Serverless edge worker — receives a customers/update webhook, reads authoritative state via the Admin API, and decides whether to publish.
  3. Storefront theme — renders the public directory, individual profile pages, a map of members, and the marketing pages.

Data is modeled as customer metafields (the editable source of truth) and a metaobject per member (the published, public-facing copy). A moderation step sits between them: members edit freely, but changes only go public after approval.

That separation — private editable data vs public approved data — is the backbone of nearly every problem and solution below.

Why a customer-account extension, not native forms

The legacy setup was a manual bottleneck: 600+ submissions but fewer than 30 members actually published, because an admin had to hand-add each approved person. The whole point of the rebuild was to replace that with self-service.

The first attempt used Shopify’s native forms for profile input — the least-code path. It broke on a core requirement: members must be able to come back and edit their profile. A native form re-submission overwrites rather than updating a known record; there was no clean “this is the same member editing their existing profile” contract. It could be coerced into working, but never cleanly.

The fix was to move input into the logged-in customer account: a customer-account UI extension writes to that member’s own customer metafields (the editable source of truth), keyed to their account. Login provides identity for free — every edit is unambiguously “this member updating their own record,” which is exactly what re-editable profiles need. That decision is what makes the moderation model below possible, and what let hundreds of legacy submissions onboard themselves instead of waiting on a manual add.

The moderation model: one boolean does the work

Members must be able to edit at any time, but the directory must only ever show approved content. Pushing every edit live would leak unreviewed changes; blocking edits until review would make the product feel dead.

The whole moderation workflow is captured by one boolean, pending_changes, with one invariant:

That collapses the webhook handler into a small, idempotent decision table:

The handler can run on every webhook delivery and safely no-op when there’s nothing to do. Approvals and re-approvals are just metafield flips an admin performs; the system converges to the correct published state on its own.

“Approved but invisible” — when correct behavior looks like a bug

A test member was approved, the metaobject existed, yet the profile never appeared. From the outside it looked like a defect.

It wasn’t. Publishing is gated by more than approval: the publish-status function returns “active” only when status is approved, the member consented to directory listing, and a profile photo exists. Otherwise the entry stays in draft and is excluded from the public query. The member had no photo, so the entry was correctly held back.

The fix wasn’t code — it was observability of the business rule. The requirement got surfaced in the form (see “Form UX” below) so members aren’t silently stuck in draft. Lesson: any rule that hides user content needs a visible explanation at the moment it applies, or “correct behavior” reads as “broken.”

Materialize at write time (a reliability decision)

The customer-account page needed a “View your public profile” link, derived from the published metaobject’s handle. The natural approach — read the metaobject reference from the customer metafield and traverse to its handle — proved unreliable across the customer-account API boundary. The link frequently failed to render.

Fix: stop deriving fragile values at read time.

Reading a scalar string has none of the access/traversal fragility of resolving a reference object. Denormalize deliberately for reliability.

A self-contained map: Leaflet + OpenStreetMap (no API key)

The directory needed a member map. The hosted map components in the newer extension channel weren’t available in the version this extension targets, and the map belongs on the storefront, not in the account UI anyway. The right move: a self-contained theme section using Leaflet + OpenStreetMap — no API key, no per-request cost, portable across themes.

Building it surfaced three instructive bugs, each worth its own lesson.

“No pins” — a single-use collection iterated twice

The map rendered but showed no markers. The same server-rendered collection was iterated twice — once for a counter, once to build the data array — and behaved as single-use, so the second pass produced nothing. Fix: iterate exactly once, compute counts on the client from the materialized data. Treat server-rendered collections as potentially single-pass.

A syntax error from an unexpected identifier

After fixing iteration, the array was still empty. The console showed a syntax error: a framework-generated section identifier (containing a double underscore) was being interpolated into a JavaScript variable name, producing invalid code. Fix: stop generating executable JS with interpolated identifiers — emit data as a <script type="application/json"> block and parse it with JSON.parse. JSON isn’t executed, so it can’t throw syntax errors. Don’t build JS identifiers from framework-generated strings.

Third-party asset flagged by the theme linter

Loading Leaflet from a public CDN tripped the platform’s theme check (“serve assets from the platform CDN”). Fix: vendor Leaflet’s CSS/JS into the theme’s own assets and replace the default marker images with CSS-only markers — removing the last external dependency.

The final map is key-less, dependency-free at runtime, lint-clean, and degrades gracefully: members without coordinates are simply skipped, with a visible “X of Y shown on the map” counter.

“The directory disappeared on a new theme” (it didn’t)

While trialing a different theme, the directory page rendered empty. It looked like data loss. It wasn’t: the directory is rendered by custom sections and templates that live only in the original theme. A fresh theme doesn’t contain them, so the page falls back to a default template and shows nothing. The data (metaobjects and storefront access) is store-level and was never affected.

The fix was conceptual:

On platforms where themes are swappable, separate data ownership from presentation ownership explicitly, and treat custom presentation as portable modules.

Designing to the platform’s seams (hosted account pages)

The hosted account area switched from top tabs to side navigation mid-build, and per-page titles moved into the nav. The full-page extension suddenly rendered a redundant — then empty — title region as we tried to match the new look.

Root cause: the page container component always renders a title region: filled when given a heading, empty when not. Removing the heading didn’t remove the region; it left an empty heading element (an accessibility smell).

Solution: stop using the page-title container for that surface and render content directly in a centered, width-constrained column. The title region disappears entirely instead of going empty.

Related: the hosted surface can’t reuse the storefront’s header/navigation — it’s a separate, platform-rendered surface. Visual consistency comes from branding settings (logo, colors) and a configurable account menu, not from injecting the theme’s header. When a wrapper always emits a region, the choice isn’t “empty vs filled” — it’s “use the wrapper vs don’t.”

Form UX: validation is communication

Several small fixes compounded into a much better application form.

Fewer dead-ends, fewer failed submissions, far less “why can’t I continue?” in user testing.

Derive what users shouldn’t have to type (geocoding, fail-soft)

The map needs coordinates, but asking members for latitude/longitude is absurd UX, and a mistyped address shouldn’t corrupt data or block a profile. So geocoding runs server-side during sync (members only ever type an address) and is engineered to never harm the primary workflow:

The map fills in automatically as members are approved, with zero extra input from members.

How the work stayed fast

A few process choices mattered as much as any single fix.

Key lessons

  1. A single, well-chosen invariant (pending_changes mirrors “current == approved”) collapses a messy moderation workflow into a tiny, testable decision table.
  2. When a value is expensive or fragile to derive at read time, materialize it at write time into the simplest readable shape.
  3. “Self-contained” is a feature — vendoring a small library and avoiding external calls makes a component portable, auditable, and resilient.
  4. Separate data ownership from presentation ownership on platforms where themes are swappable.
  5. On managed UI surfaces, design to the platform’s seams — don’t fight wrappers that always emit a region.
  6. Validation is communication: surface requirements early, never hide the path forward.
  7. Derive it, and let it fail soft — never let a nice-to-have step block the core workflow.