DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Typing "pubs" into our business search puts Public safety service first, and the fix was not better matching

On the Pro and Business plans, Nakodo also runs outreach to other businesses: pick the kinds of business and the places, as the pricing page puts it, "cafes within 10 km of Leeds or salons across Ireland". The setup screen therefore needs a box where you type what kind of business you mean, and get suggestions as you type.

That box turned out to be four decisions worth writing down, and one honest flaw.

Decision one: a GET route handler, not a Server Action

Everything else in our app that talks to the server from the client is a Server Action. This one is not, and the reason is in the Next docs shipped with the package:

Next.js dispatches Server Actions one at a time per client. If a user triggers three actions in quick succession, the second waits for the first to finish, then the third waits for the second.

A consequence: do not rely on Promise.all to parallelize Server Actions from the client. If you need parallel work, do it inside a single Server Action, fetch in parallel from a Server Component, or use a Route Handler for non-mutation requests.

A typeahead is the exact shape that breaks against a queue. Six keystrokes mean six requests, each waiting for the one before, and the suggestions for "cafe" arrive after the suggestions for "c", "ca" and "caf" have each taken a round trip in order. Worse, in a setup form the user is also saving things, so a lookup can end up queued behind a save.

The whole endpoint:

export async function GET(request: NextRequest) {
  const user = await getUser();
  if (!user) return new Response("Unauthorized", { status: 401 });
  const limited = await rateLimitResponse("lookup", userKey(user.id));
  if (limited) return limited;
  const q = (request.nextUrl.searchParams.get("q") ?? "").slice(0, 60);
  return Response.json(searchCategories(q), { headers: { "Cache-Control": "private, max-age=3600" } });
}
Enter fullscreen mode Exit fullscreen mode

Four lines of policy before the work: a session, a rate limit (300 a minute per account, because it fires on keystrokes and a stuck key should not look like an attack), a hard 60-character cap on the query, and private caching so a shared cache never holds one account's lookups.

Decision two: the taxonomy ships with the server

searchCategories touches no database and calls nobody. The data is a JSON file in the repository: 2,150 categories from the Overture Maps places schema, each with its parent and a count of how many places worldwide sit in it, including everything beneath it.

[["services_and_business", null, 21176037], ["shopping", null, 14395494], ["food_and_drink", null, 10559837], ...]
Enter fullscreen mode Exit fullscreen mode

Tuples rather than objects, which is why 2,150 rows and 6,450 values fit in 105 KB. It is loaded once into a Map on the server, and that is the entire storage story:

const CATEGORIES = new Map<string, Category>(
  (rows as [string, string | null, number][]).map(([code, parent, count]) => [code, { code, parent, count }]),
);
Enter fullscreen mode Exit fullscreen mode

Twelve roots (services_and_business, food_and_drink, lodging and nine others), and the tree goes five levels deep in places. That depth is the first thing that surprised me about somebody else's taxonomy: pub is not under food_and_drink directly, it is under bar, and pizza_restaurant is under italian_restaurant.

So suggestions show the parent as context, because "Pizza restaurant" alone does not tell you what you are about to select:

// "Restaurant" for "pizza_restaurant": the category a code sits under.
export function parentLabel(code: string): string | null {
  const parent = CATEGORIES.get(code)?.parent;
  return parent ? categoryLabel(parent) : null;
}
Enter fullscreen mode Exit fullscreen mode

(The comment is older than the data. The real answer for pizza_restaurant is "Italian restaurant", which is better.)

Decision three: ranking is a break, not a sort

The file is generated sorted by count, descending. Which means "most common first" costs nothing at query time:

for (const c of CATEGORIES.values()) {
  const name = c.code.replace(/_/g, " ");
  if (!words.every((w) => name.includes(w))) continue;
  out.push({ code: c.code, label: categoryLabel(c.code), within: parentLabel(c.code) });
  if (out.length >= limit) break; // rows are sorted by count
}
Enter fullscreen mode Exit fullscreen mode

A Map iterates in insertion order, insertion order is the file's order, and the file's order is the ranking. No score, no sort, no index. It stops after eight matches, so a one-letter query walks a few hundred rows and leaves.

And every typed word has to match, as a substring, against the code with its underscores read as spaces. pizza restaurant finds pizza_restaurant. Two words both have to land, which is what makes a short query useful rather than noisy.

Decision four: plurals, with one lookbehind that matters

People type "pubs", "cafes", "bakeries". The category codes are singular. So each word gets crudely singularised before matching:

.map((w) => w.replace(/(ies)$/, "y").replace(/(?<=[^s])s$/, ""))
Enter fullscreen mode Exit fullscreen mode

"Bakeries" becomes "bakery". "Cafes" becomes "cafe". And (?<=[^s])s$ rather than s$ is the whole reason this is safe: it refuses to strip the s when the letter before it is also an s, so "business" stays "business" instead of becoming "busines" and matching nothing. Try it the lazy way and the single most likely word in a business-type search stops working.

Four of these are pinned in the test file, including the two that would break silently:

test("search matches plurals and ranks common categories first", () => {
  const pubs = searchCategories("pubs");
  assert.ok(pubs.some((m) => m.code === "pub"));
  assert.equal(searchCategories("cafes")[0].code, "cafe");
  assert.equal(searchCategories("bakeries")[0].code, "bakery");
  assert.deepEqual(searchCategories("  "), []);
  assert.equal(searchCategories("pizza restaurant")[0].code, "pizza_restaurant");
});
Enter fullscreen mode Exit fullscreen mode

Look closely at the first assertion. It says pubs must contain pub. It does not say pub comes first, and that is not laziness.

The flaw I left in

Here is what the search actually returns for "pubs":

pubs -> Public safety service (in Community and government)
        Pub (in Bar)
        Public utility (in Community and government)
        Public transit facility or service (in Ground transport facility or service)
Enter fullscreen mode Exit fullscreen mode

Substring matching plus count ranking does exactly what it promised: "pub" is inside "public", and there are a great many more public safety services in the world than pubs. The person typing "pubs" wanted the second row.

The tempting fixes are all worse than they look. Prefer whole-word matches and hot dog restaurant stops surfacing for "dog". Prefer prefix matches on the code and gastropub disappears for "pub". Add a hand-tuned score and you have taken on a relevance model inside a form field, with 2,150 rows of someone else's taxonomy as the corpus and no way to measure whether a change helped.

What is actually in front of the user is this: twelve tiles for the common cases, and the search box underneath for everything else.

export const BUSINESS_TYPES: BusinessTypeTile[] = [
  { id: "pubs", label: "Pubs and bars", icon: "beer", categories: ["bar", "gastropub"] },
  { id: "cafes", label: "Cafés", icon: "coffee", categories: ["cafe", "coffee_shop", "tea_room"] },
  { id: "restaurants", label: "Restaurants", icon: "utensils", categories: ["restaurant"] },
  // ...nine more
];
Enter fullscreen mode Exit fullscreen mode

Nobody hunting pubs has to type "pubs", because "Pubs and bars" is the first tile, and it selects bar plus gastropub rather than the narrower pub, so it covers the ones not labelled as pubs too. The search exists for the long tail: dog groomers, dental labs, music venues. For a query like "dog" the substring behaviour is not a wart at all, it is the feature.

A test keeps the tiles honest against the data, which is the one thing that genuinely can rot:

test("every tile's categories exist", () => {
  for (const t of BUSINESS_TYPES) for (const c of t.categories) assert.ok(isCategory(c), `${t.id}: ${c}`);
});
Enter fullscreen mode Exit fullscreen mode

Overture publishes monthly. If a category is renamed or folded into another, a tile silently selecting nothing is the kind of bug that shows up as "my campaign found no cafes", weeks later, from a customer. That assertion turns it into a failed build.

The general lesson

I spent an hour thinking about relevance scoring for a text box, and the better answer was twelve buttons. Search quality work is a product decision about which queries matter, and the cheapest way to make the top queries perfect is to remove them from the search entirely.

How Nakodo works covers what happens after you have picked a type and a place: what gets imported, what gets read, and what never gets collected.

Top comments (0)