Personalized Search: Joining Per-User State Onto Shared Results

The Problem

A signed-in user expects two kinds of information from a search request: facts about the item that are true for everyone — its name, location, etc. — and facts that are true only for them — whether they’ve seen it, saved it, watched it, visited it, etc..

These two kinds of data have nothing in common except the item’s ID. The public facts come from the scraped, normalized catalog and are identical for every visitor. The personal facts live in per-user tables (saved_post, following_user) and exist only for one account. A search result on screen is a single row, but it is assembled from two unrelated sources.

The question the architecture had to answer was where those two sources meet.

The Experience We Wanted

The search itself should not depend on who is asking. A signed-out visitor and a signed-in user issuing the same query should get the same ranked, filtered set of items in the same order. Sign-in adds a layer on top — a saved marker on the items the user has saved — but it must not change which items appear or how they rank.

That split is also a caching split. The expensive geospatial, faceted query against the catalog is the same for everyone, so it can be cached and shared. The personal layer is cheap, changes often, and is private. Each half can go stale and refresh on its own schedule.

What That Forced On Us

A viewer-independent result set has a hard consequence: the query that produces it cannot reference the current user at all. The user’s identity has to enter after the search has decided what the results are.

That rules out the most direct implementation: a single database query that joins the catalog against the user’s saved tables and returns the merged rows in one trip. Such a join folds the user into the search itself — its shape, and potentially its filtering and ordering, now depend on which account is asking. The same query can no longer serve everyone, nor be cached once and reused.

So the user state had to be applied as a second step, against a result set that was already final. Two phases:

  1. Search. Run the public query. Get back a list of items — and, for the next phase, their IDs.
  2. Overlay. Take those IDs, look up only this user’s state for exactly those IDs, and attach it to the matching items.

The second phase is bounded by the first. It never asks “what has this user saved?” in general — only “of these specific items on screen, which has this user saved?” If the search returned thirty dishes, the personal lookup is scoped to those thirty IDs and no more.

How We Built It

The two phases are chained by a query factory, createSupabaseQueryWithUserState. It takes a queryFn that runs the public search and an applyUserState step that runs after. It wires them in sequence:

queryFn: async ({ supabase, isSignedIn, userId }, params) => {
  const data = await config.queryFn({ supabase, isSignedIn, userId }, params);
  return config.applyUserState(data, isSignedIn, supabase, userId);
},
refetchOnAuthChange: true,

refetchOnAuthChange is the line that makes the layering honest: when the user signs in or out, the query re-runs and the overlay is recomputed. The public results don’t change; their saved markers do.

A concrete search wires the two halves together. useListingSearch calls the catalog RPC search_listings — passing location, radius, category, dietary filters, and so on, none of which mention the user — and hands the result to the listing overlay:

queryFn: async ({ supabase }, params) => {
  return callRpc(supabase, RPC_FUNCTIONS.SEARCH_LISTINGS, { /* filters only */ });
},
applyUserState: (results, isSignedIn, supabase, userId) =>
  withListingUserState(results, isSignedIn, supabase, userId),

The overlay is where the second query lives. It pulls the IDs out of the result set and asks for state on just those IDs:

const listingIds = results.map((r) => r.listing_id);
const savedListings = await fetchSavedListingsWithDetails(
  supabase,
  userId,
  listingIds,
);
const savedMap = new Map(savedListings.map((item) => [item.itemId, item]));
return mergeListingUserState(results, savedMap);

The scoping is one clause in the fetch — .in("listing_id", listingIds) — which is what keeps the personal query proportional to what’s on screen rather than to the size of the user’s whole library.

The merge is a lookup, not a join. Build a Map from ID to the user’s saved record, then walk the public results and stamp each one:

return canonicalResults.map((result) => {
  const saved = savedListings.get(result.listing_id);
  return {
    ...result,
    userState: saved
      ? { isSaved: saved.isSaved ?? false, isTried: saved.isTried ?? false }
      : { isSaved: false },
  };
});

The public row passes through untouched; userState is added as a new field. An item the user hasn’t saved still gets a userState, with isSaved: false, so the UI never has to distinguish “not saved” from “we didn’t check.”

Merchants follow the identical shape — withMerchantUserState, fetchSavedMerchantsWithDetails, mergeMerchantUserState — which is why the overlay step is a named seam rather than logic buried in each search hook. The pieces arrived together in one commit, “Wire up saved items,” and the same factory now backs merchant search, listing search, and the single-entity detail hooks.

What Happens When There’s No User

Every overlay opens with the same guard:

if (!isSignedIn || !userId || results.length === 0) {
  return mergeListingUserState(results, new Map());
}

A signed-out viewer, or an empty result set, skips the personal query entirely and merges against an empty Map. Every item comes back with isSaved: false. The output type is the same whether or not anyone is signed in, so the rendering layer has one shape to handle. The personal lookup is also wrapped so that if it fails, the overlay falls back to the empty Map — search degrades to its signed-out appearance rather than failing the whole result.

What It Still Lacks

The two phases are sequential: the overlay can’t start until the search returns, because it needs the IDs. For the result-set sizes search returns this is a short second round-trip, but it is a second round-trip, not a parallel one.

The overlay reuses fetchSavedListingsWithDetails, which selects the item’s name, score, merchant, and coordinates alongside the saved flags. The overlay throws all of that away and keeps only isSaved and isTried — it already has the public fields from phase one. The fetch is doing more work than the overlay needs because it was built to also serve the “show me my saved items” screen, where those joined details are the point.

The merge keys strictly on ID equality. It assumes the public result and the saved record refer to the same item by the same identifier — which holds today, but the catalog’s own normalization (aliases, merged duplicates) is exactly the kind of change that could put a saved row’s ID out of step with the canonical ID a search now returns.

Roads Not Taken

One query with a join

The merged-query approach was the obvious alternative: join the catalog to the user’s saved tables in the database and return finished rows. It saves the round-trip and the client-side merge. It was rejected because it makes search viewer-dependent. The same query can no longer serve signed-out and signed-in users, the public result can’t be cached independently of the user, and the personal data — which changes far more often than the catalog — drags the whole result’s cache lifetime down with it. The separation is what lets the public half cache long and the personal half refresh on auth change.

Load the full profile, merge on the client

The other alternative was to load the user’s entire saved set once, hold it on the client, and tag search results against it in render — no per-search personal query at all. For a user with a small library this is fewer requests. It was rejected on scaling: the cost grows with the size of the user’s library rather than with what’s on screen, and it puts the burden of holding and invalidating that full set on the client. The ID-scoped overlay inverts that — it never fetches more personal state than the current results can reference, regardless of how much the user has saved.

The chosen design pays for that with an extra query per search and a fetch that reads more columns than it uses. Those are the costs the two earlier sections name. They were accepted in exchange for a search path that is identical for every viewer, cacheable on its own terms, and a personal layer whose cost is bounded by the page rather than by the account.