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:
- 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.
- Serverless worker — gets a
customers/updatewebhook, reads the real state from the Admin API, and decides whether to publish. - 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:
pending_changes == falsemeans the private data matches the last approved data.- Any edit to a public field by an approved member sets
pending_changes = true.
That turns the webhook handler into a small decision table that’s safe to run many times:
- Not approved → keep the public entry hidden (draft).
- Approved + pending changes → keep the last approved version; only fix the publish status.
- Approved + no pending changes → publish the current data (create it on first approval, otherwise update it).
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.
- Backend: on every successful publish, save the handle into a simple text field (best-effort; if it fails, it never blocks the sync).
- Client: read the plain text first; only fall back to following the reference if the text is missing (so older records still work, no migration needed).
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:
- Store-level data (records, field access) doesn’t depend on the theme.
- Presentation (sections/templates) must be copied over to any new theme.
- I kept the custom sections self-contained (scoped CSS, no cross-section dependencies), so moving them is a file copy plus re-assigning the page template.
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.
- Don’t disable the submit button to “force” a valid form. On a long form, disabling submit until it’s valid gives users no idea what’s missing. Keep submit active, check on submit, and show one summary listing every missing field. (A short, pre-filled edit form can disable-until-valid; a long first-time form shouldn’t.)
- Mark required fields up front. Use the native
requiredattribute so they’re marked before anyone types. Where a control didn’t support it, the label showed the mark. - Give copy-paste examples for formatted fields. URL fields that need a full
https://value show an example (https://instagram.com/...). Where the control had no placeholder, a small helper line under the field did the job. - State limits where they apply. Image uploads show the accepted formats, size limit, and count up front — not as an error afterward.
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:
- Optional / can be switched off instantly.
- Non-blocking — a failed or low-confidence result never stops a profile from syncing or publishing; the member just doesn’t get a map pin yet.
- Cached — an unchanged address isn’t looked up again.
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.
- Check against the real source, not memory. I validated UI components and their options against the platform’s own tooling before shipping. That caught bad options (a control that didn’t support
placeholder, button styles that didn’t exist) before deploy. Guessing what an API supports is the most common avoidable time-sink. - Tight edit → check → deploy loop, clean git habits. Frontend and backend in separate repos; backend auto-deployed from
main; small commits made the occasional revert easy. - Separate who runs what. A person ran deploys, store config, and infrastructure; the assistant only edited, checked, and pushed. Risky, hard-to-undo actions stayed with a human while iteration stayed fast.
- Diagnose before patching. Several “bugs” — invisible profile, empty map, vanishing directory — weren’t code defects. They were a business rule, a read-once list, a naming rule, and theme portability. I found the root cause each time, often with a single temporary line that printed exactly what crossed the boundary.
- Ship the MVP, defer the rest. With a small first group of members, the right move was to ship the core flow (apply → approve → publish → appear in directory/map) and onboard real users. Real use showed what to build next.
Key lessons
- One well-chosen rule (
pending_changesmirrors “current == approved”) turns a messy review workflow into a tiny, testable decision table. - 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.
- “Self-contained” is a feature — bundling a small library and avoiding outside calls makes a component portable and reliable.
- Keep data and presentation separate where themes are swappable.
- On managed UI, work with the platform’s edges — don’t fight wrappers that always draw an area.
- Validation is communication: show requirements early, never hide the way forward.
- Work it out, and let it fail soft — never let a nice-to-have step block the core flow.