Shopify member directory — moderated profiles, a members map, and no API key

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. It has three parts: a profile editor inside each member’s account (a customer-account UI extension), a serverless worker that reviews edits and publishes approved profiles, and a storefront theme that shows the directory and a map.

The hard part wasn’t any single piece. It was the rule that decides what goes public, plus a few small choices (save values when you write them, bundle your own assets, work out map coordinates on the server) that kept the system reliable.

Context

Three parts:

  1. Profile editor (customer-account UI extension) — a “Membership” page inside the member’s account. They fill in a profile (bio, role, business details, social links, photo, gallery), confirm they’re eligible, and submit.
  2. Serverless worker — gets a customers/update webhook, reads the real state from the Admin API, and decides whether to publish.
  3. Storefront theme — shows the public directory, each member’s page, the map, and the marketing pages.

The data lives in two places: customer metafields are the private, editable copy (the source of truth), and a metaobject per member is the public, published copy. A review step sits between them: members can edit anytime, but changes only go public after approval.

That split — private editable data vs public approved data — is behind almost every problem and fix below.

Why a profile editor in the account, not a native form

The old setup was a manual bottleneck: 600+ submissions but fewer than 30 members actually shown, because an admin had to add each approved person by hand. The point of the rebuild was to make it self-service.

My first attempt used Shopify’s native forms — the least-code option. It failed on one core need: members must be able to come back and edit their profile. When you submit a native form again, it overwrites instead of updating the existing record. There was no clean way to say “this is the same member editing their own profile.” I could force it to work, but never cleanly.

The fix was to put the editor inside the logged-in account. The extension writes to that member’s own customer metafields, tied to their account. Login gives you identity for free, so every edit clearly belongs to that member — exactly what editable profiles need. This is what makes the review model below work, and what let hundreds of old submissions onboard themselves instead of waiting for a manual add.

The review model: one true/false value does the work

Members must be able to edit anytime, but the directory must only show approved content. Publishing every edit would leak unreviewed changes. Blocking edits until review would make the product feel dead.

The whole review workflow rests on one true/false value, pending_changes, with one rule that always holds:

That turns the webhook handler into a small decision table that’s safe to run many times:

The handler can run on every webhook and simply do nothing when there’s nothing to do. Approvals are just an admin flipping a value; the system settles into the right published state on its own.

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

A test member was approved and the public record existed, but the profile never showed up. From outside it looked broken.

It wasn’t. Publishing needs more than approval: a profile goes live only when it’s approved, the member agreed to be listed, and a photo exists. Otherwise it stays a draft and is left out of the public list. This member had no photo, so it was correctly held back.

The fix wasn’t code — it was making the rule visible. I surfaced the requirement in the form (see “Form UX” below), so members aren’t silently stuck. Lesson: any rule that hides someone’s content needs a visible explanation at the moment it applies, or “correct” looks “broken.”

Save the value when you write it (a reliability choice)

The account page needed a “View your public profile” link, built from the published record’s handle. The obvious approach — read a reference to the public record and follow it to the handle — was unreliable across the account API. The link often failed to show.

Fix: stop working out fragile values at read time.

Reading a plain string has none of the fragility of following a reference. Store a copy on purpose when it buys you reliability.

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

The directory needed a map. The hosted map components weren’t available in the extension version I was targeting, and the map belongs on the storefront anyway. So I built a self-contained theme section with Leaflet + OpenStreetMap — no API key, no per-request cost, and it moves easily between themes.

Building it turned up three useful bugs.

“No pins” — a list read twice

The map showed up but had no markers. The same server-rendered list was read twice — once for a count, once to build the data — and it could only be read once, so the second pass got nothing. Fix: read it once, and count on the client from the data you already built. Treat server-rendered lists as read-once.

A syntax error from an unexpected name

After fixing that, the data was still empty. The console showed a syntax error: an auto-generated section id (with a double underscore) was being dropped into a JavaScript variable name, which made invalid code. Fix: stop generating code with those names. Output the data as a <script type="application/json"> block and read it with JSON.parse. JSON isn’t run as code, so it can’t cause a syntax error. Don’t build JavaScript names from auto-generated strings.

An outside asset flagged by the theme check

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

The final map needs no key, has no runtime dependencies, passes the theme check, and fails gracefully: members without coordinates are skipped, with a visible “X of Y shown on the map” counter.

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

While testing a different theme, the directory page came up empty. It looked like lost data. It wasn’t: the directory is drawn by custom sections and templates that live only in the original theme. A new theme doesn’t have them, so the page falls back to a default and shows nothing. The data (the public records and storefront access) lives at the store level and was never touched.

The fix was a change in thinking:

Where themes are swappable, keep data and presentation clearly separate, and treat your custom presentation as portable pieces.

Work with the platform’s edges (hosted account pages)

Mid-build, the hosted account area switched from top tabs to side navigation, and page titles moved into the nav. Suddenly my full-page extension showed a duplicate — then empty — title area as I tried to match the new look.

Cause: the page container always draws a title area — filled when you give it a heading, empty when you don’t. Removing the heading didn’t remove the area; it left an empty heading (bad for accessibility).

Fix: stop using the page-title container here, and put the content directly in a centered, narrow column. The title area disappears completely instead of sitting empty.

Related: the account area can’t reuse the storefront’s header — it’s a separate, platform-drawn surface. Consistency comes from branding settings (logo, colors) and the account menu, not from injecting the theme’s header. When a wrapper always draws an area, the choice isn’t “empty vs filled” — it’s “use the wrapper or don’t.”

Form UX: validation is communication

A few small fixes added up to a much better application form.

The result: fewer dead-ends, fewer failed submissions, and far less “why can’t I continue?” in testing.

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

The map needs coordinates, but asking members for latitude/longitude is terrible UX, and a mistyped address shouldn’t corrupt data or block a profile. So the address is turned into coordinates on the server during sync (members only type an address), and it never harms the main flow:

The map fills in on its own as members are approved, with no extra input from them.

How the work stayed fast

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

Key lessons

  1. One well-chosen rule (pending_changes mirrors “current == approved”) turns a messy review workflow into a tiny, testable decision table.
  2. When a value is expensive or fragile to work out at read time, save it when you write in the simplest form to read back.
  3. “Self-contained” is a feature — bundling a small library and avoiding outside calls makes a component portable and reliable.
  4. Keep data and presentation separate where themes are swappable.
  5. On managed UI, work with the platform’s edges — don’t fight wrappers that always draw an area.
  6. Validation is communication: show requirements early, never hide the way forward.
  7. Work it out, and let it fail soft — never let a nice-to-have step block the core flow.