From 50 Scattered Avatars to One: Putting a Membership Ring on Every User

The trigger
The membership badge — a purple ring around the user's avatar — only appeared on the logged-in user's own avatar in the top nav. A reasonable complaint came in: "If I'm paying for membership, why am I the only one who can see it on myself?" The ring needed to show up on every avatar the user sees, not just their own.
Three problems surfaced once we started looking:
Nobody knew how many avatars there were. There was no shared
Avatarcomponent — every page rendered its own inline markup.There was no pattern for fetching someone else's membership state. The existing code only checked the logged-in user, once, in the nav.
Touching the same design in 30 places by hand was out of the question. Touch it once, never want to see it again.
Discovery — it was 50, not 30
A codebase sweep with an exploration agent surfaced avatar rendering scattered across roughly 50 files, with no consistent pattern:
Size: w-5 / w-7 / w-8 / w-9 / w-10 / w-11 / w-12 / w-14 / w-[52px] / w-24
Image: next/image (width/height or fill) mixed with plain <img>
Fallback: User icon / default SVG / first letter (gradient or muted bg)
Wrapper: wrapped in <Link> / plain <div>
Even places that clearly intended the same design had slightly different class names — bg-primary/20 here, bg-primary/15 there. Preserving every micro-variant while adding only a membership ring would have meant 50 hand-touches.
The decision: absorb everything into a shared <MemberAvatar> and rewrite the call sites in one pass.
Three design decisions
1. Where does the membership data come from?
The membership table already existed:
create table memberships (
user_id uuid not null,
status varchar(20) not null,
expires_at timestamptz not null,
...
);
create unique index idx_active_members
on memberships(user_id)
where status in ('active', 'past_due');
The key piece is that partial unique index: only rows in active/past_due status are indexed, so the index size scales with the count of currently active members — not with the table's lifetime row count. Expired old memberships aren't in it.
Three options were on the table:
ApproachProConDenormalize users.is_member + triggerComes free with existing user queries, zero extra round tripsDB migration, trigger maintenance, awkward expiration semanticsAdd is_member to every existing data-fetch functionRSC pattern stays the sameHave to touch 10+ fetch functions covering feeds, posts, messages, etc.Batch-fetch at the page-level RSCZero DB or fetch-function changes; only +1 query per pageEach page needs a bit of boilerplate to gather user IDs
Option 3 won. One query per page — WHERE user_id = ANY($1) AND status='active' AND expires_at > now() — and the partial index does its job.
export async function fetchActiveMemberIds(
supabase: SupabaseClient,
userIds: (string | null | undefined)[]
): Promise<Set<string>> {
const uniq = Array.from(new Set(userIds.filter((id): id is string => !!id)));
if (uniq.length === 0) return new Set();
const { data } = await supabase
.from("memberships")
.select("user_id")
.in("user_id", uniq)
.eq("status", "active")
.gt("expires_at", new Date().toISOString());
return new Set((data ?? []).map((r) => r.user_id));
}
2. How to avoid prop drilling?
Avatars sit deep in the render tree. Threading isMember down five levels every time would be painful. A Context provider scoped to the page holds the member Set:
export function MembersProvider({
initial, children,
}: { initial: string[]; children: React.ReactNode }) {
const [set, setSet] = useState<Set<string>>(() => new Set(initial));
const addMembers = useCallback((ids: string[]) => {
setSet((prev) => {
const next = new Set(prev);
ids.forEach((id) => id && next.add(id));
return next;
});
}, []);
// ...
}
export function useIsMember(userId: string | null | undefined) {
return useContext(MembersContext).isMember(userId);
}
addMembers is the escape hatch for client-side scenarios — infinite scroll, newly posted comments — where new user IDs show up after the initial render. The pattern looks like this:
// Page-level RSC
const visibleUserIds = [feedAuthorId, ...commentAuthorIds, ...participantIds];
const memberSet = await fetchActiveMemberIds(supabase, visibleUserIds);
return (
<MembersProvider initial={Array.from(memberSet)}>
<FeedDetailClient ... />
</MembersProvider>
);
// Client side, after fetching new comments
const refreshed = await fetchComments(feedId);
setComments(refreshed.data);
const newIds = refreshed.data.map((c) => c.userId);
const members = await fetchActiveMemberIdsAction(newIds);
if (members.length > 0) addMembers(members);
3. How much variation should the component absorb?
size got standardized into 6 tokens, but real pages had off-spec pixel sizes (w-7, w-11, w-14). The component accepts a className override, and since cn() flows through tailwind-merge, any conflicting w-X from the override wins:
<MemberAvatar
size="sm"
className="w-7 h-7" // forces 28px, overriding size="sm"'s 32px
userId={author.id}
imageUrl={author.avatarUrl}
nickName={author.displayName}
/>
isMember can be passed explicitly (useful for the logged-in user, whose state we already know), or omitted — in which case the component falls back to the Context.
Design evolution — a solid ring doesn't feel like paying for something
The first pass used ring-2 ring-[brand-purple]. The build went green; the screen did not. At small sizes (w-8) it was almost invisible, and at large sizes it just read as "oh, that has a purple outline." Now I understood why other SaaS products use gradient treatments for premium markers.
The redesign:
Solid
ring-2→ gradient padding wrapperThickness scales with size (
xs: 2px→2xl: 4px)A three-stop gradient: purple → lilac → magenta
CSS ring only supports solid colors, so a gradient ring needs the wrapper-padding-plus-inner-div trick: the outer element carries the gradient background and padding, the inner element clips the actual avatar.
const wrapperClass = cn(
"rounded-full shrink-0 inline-flex items-center justify-center",
s.box,
isMember
? cn(s.pad, "bg-gradient-to-tr from-[#7C3AED] via-[#A855F7] to-[#EC4899]")
: cn(
"bg-muted ring-2 ring-transparent",
interactive && "hover:ring-primary/50",
),
className,
);
return (
<div className={wrapperClass}>
<div className="w-full h-full rounded-full overflow-hidden bg-muted">
{imageOrFallback}
</div>
</div>
);
Non-members keep a transparent ring-2 so the layout doesn't shift when membership toggles on or off. Without that, every avatar in the app would jiggle by a pixel the moment someone subscribes.
Performance — how expensive is +1 query per page, really?
Not very. Three reasons:
The partial unique index turns
user_id = ANY($1)into an index lookup. The index itself only contains currently active members, so it stays small regardless of historical membership churn.The query slots into the page's existing
Promise.all(...), so it adds nearly nothing to wall-clock latency.Membership freshness at minute-granularity is fine for this UI affordance, which means caching can drive the cost arbitrarily close to zero when traffic justifies it.
Optimization staircase as scale grows:
ThresholdOptimizationSame request hits the fetch multiple timesWrap fetchActiveMemberIds with React cache() to dedupe within a requestTraffic high enough that 1 query per page mattersunstable_cache with a 1–5 minute TTLMembership table at hundreds of thousands of rows, pages with hundreds of user IDsDenormalize users.member_until with a trigger; the column rides along with existing user queries for free
Today, none of that is needed. Keep the simple version, climb the staircase only when something asks you to.
Retrospective
Why a one-line design request became a 50-file diff: because there was no shared component. After this work, adding any user-tier affordance — an online dot, a verification check, anything — is a one-file change in
MemberAvatar.The biggest win came on the second change, not the first. Swapping the solid ring for a gradient ring touched one file and propagated to all 50 call sites automatically. The value of consolidation is felt the second time you change something, not the first.
Picking the data source was the highest-leverage decision. Denormalization with triggers would have brought migrations, rollback scenarios, and trigger maintenance. The RSC + Context combination kept the schema untouched.
Small details matter: keeping
ring-2 ring-transparentfor non-members (no layout shift), tuning padding per size, allowingclassNameoverrides throughtailwind-merge. Each one is what makes "feels different, but the code lives in one place" actually true.
One-line takeaway: collapse a scattered pattern into one component, and the next design tweak ships as a single PR instead of fifty. The real deliverable of this project wasn't the purple ring — it was
MemberAvatar.