How to Shape a Search API for a Product
A search box is the most demanding interface a product team will ever ship. It accepts anything a person can type, at any length, in any order, with typos, half-remembered names, and quiet assumptions about what the system already knows. Then it has to answer in a few hundred milliseconds and look effortless doing it. Most product teams treat this as a backend problem and hand it to whoever owns the index. That handoff is where search APIs go wrong.
The API is the contract between what a person means and what the product can find. Shaping it well means deciding, early and explicitly, which parts of that translation the API owns and which parts the client owns. Get that boundary wrong and every later feature, from autocomplete to filters to ranking tweaks, becomes a patch on a leaky seam.
Start from the query grammar, not the endpoint list
Most search APIs begin life as a REST resource with a query string parameter called q. That single string then absorbs everything: the search terms, the category, the price range, the sort order, the page number. It works for a demo and collapses under real use. A person typing "red running shoes under 80" expects the system to parse intent. A product that sends that string to a naive term matcher gets nonsense back, and the team blames the index.
The fix is to design the query grammar before the endpoints. Decide what a query can contain: free text, structured filters, a sort directive, a pagination cursor, a locale, a result count. Write those down as first-class fields. The free-text field should accept natural language and be treated as a hint, not a command. Everything else should be typed and validated at the edge.
This sounds bureaucratic. It pays off the first time a mobile client needs to send a filter for "in stock only" and the backend has to guess whether that arrived inside the free-text blob or as a separate boolean. Typed fields end the guessing.
Decide who owns ranking before writing a line of code
Ranking is the part of search that teams argue about longest and specify last. The API has to answer a question that sounds simple: given these results, in what order should they appear? The honest answer is that the ordering depends on context the API may not have. A shopper browsing a category wants popular items first. The same shopper searching for a specific model number wants exact matches first, popularity be damned.
Two workable patterns exist. In the first, the API owns ranking entirely and exposes a small set of named sort modes (relevance, price ascending, newest, best selling). The client picks a mode and the backend handles the rest. In the second, the API returns scores and lets the client re-rank within a window. The first is simpler to reason about and easier to cache. The second gives product teams room to experiment but invites inconsistency across clients.
A useful middle ground: the API owns the default relevance order, but accepts an explicit sort override and a small, documented set of boosts (for example, prefer items in the user's region). Boosts are typed and bounded. The client cannot inject arbitrary scoring logic, which keeps the ranking explainable when someone asks why a result appeared where it did.
Pagination models that survive a shifting index
Offset pagination is the default in almost every framework, and it is the wrong default for search. Page two of a query is not a stable set. Between requests, items are added, removed, or re-ranked. A user who clicks to page two can see a duplicate of an item from page one, or miss an item entirely, because the offset slid underneath them.
Cursor pagination fixes most of this. The API returns an opaque cursor that encodes the last seen position in the ranked list, and the next request asks for results after that cursor. The client never computes offsets. The index can change and the cursor still points somewhere sensible. The tradeoff is that cursors are harder to cache and cannot jump to an arbitrary page, which is fine because almost no real user jumps to page seventeen of search results.
Ship cursor pagination for the result list and reserve offset pagination for admin tools that genuinely need to enumerate everything. Mixing the two in one endpoint produces a contract that nobody can hold in their head.
The response shape is a product decision
What comes back in the response body determines what the frontend can render without a second round trip. A bare list of IDs forces the client to fetch each item, which doubles latency and makes the search feel sluggish. A response that includes the fields needed for a result card (title, thumbnail, price, availability, a short snippet) lets the client paint immediately.
There is a real tension here. Fat responses are convenient and slow to serialize. Thin responses are fast and push work onto the client. The resolution is to make the returned fields explicit and selectable. The API accepts a fields parameter listing what the caller needs, and returns exactly that, with sensible defaults for the common case. A mobile result list asks for five fields. A detail hover card asks for twelve. Neither pays for the other's payload.
Include result metadata in the same response: total count when cheap to compute, whether more results exist, the cursor for the next page, and any query corrections the system applied (did you mean, filters that were dropped because they matched nothing). That metadata is the difference between a search UI that feels responsive and one that feels like it is hiding things.
Typo tolerance has a budget, and the API should enforce it
Fuzzy matching is the feature everyone asks for and nobody scopes. Left unbounded, it turns "cat" into a search for "car", "cart", "coat", and "bat", and the user sees a wall of irrelevant results with no explanation. Typo tolerance needs a distance budget tied to term length. Short terms get little or no fuzz. Longer terms get a character or two of slack.
The API should expose this as a documented parameter with a conservative default, not as hidden magic. When the system corrects a query, the response should say so in metadata so the UI can surface "showing results for" and let the user revert. Silent correction is a small betrayal that users notice.
Empty results deserve their own contract
A search that returns nothing is a failure state, and the API should treat it as one with a defined shape. Returning an empty array with a 200 status tells the client nothing about why. Was the query malformed? Did a filter exclude everything? Is the index empty for this locale?
A better contract returns a structured empty result: a reason code, the filters that were applied, and optionally a set of relaxations the client can offer ("remove the price filter to see 40 results"). The client decides how to present this. The API supplies the facts. This one decision prevents a hundred frontend workarounds where each client invents its own empty-state logic.
Version the query language, not just the URL
API versioning usually means a v1 and v2 in the path. For search, the more important version is the query language itself. If the meaning of the free-text field changes, or the default ranking shifts, old clients break in ways that path versioning does not catch.
Expose a query language version as a request parameter. When the ranking model changes, bump the language version and keep the old behavior available for a deprecation window. Clients that pin a version get stable behavior. Clients that opt into the new version get the improvement. This is the only way to evolve search ranking without silent regressions in production dashboards that nobody can explain.
Latency budgets belong in the API spec
Search feels instant below roughly 200 milliseconds and feels broken above about a second. The API spec should state a target latency for the common query and a maximum for the worst case. Those numbers drive real decisions: whether to precompute popular queries, whether to cap the result window, whether to return approximate counts instead of exact ones.
Approximate counts are a good example. Exact total counts require scanning the full result set, which is expensive on large indexes. Returning "about 1,200 results" or "1,000+" is almost always good enough for a UI and far cheaper. The API decides this, documents it, and the client stops assuming the number is exact.
Instrument the query, not just the endpoint
Standard API metrics (requests per second, error rate, p99 latency) tell a team whether the search service is up. They say nothing about whether search is working. The useful instrumentation lives at the query level: what terms people typed, which queries returned zero results, which queries were followed by a refinement, which result positions got clicked.
Building this into the API from the start means logging the raw query, the parsed representation, the applied filters, the result count, and a query ID that the client can echo back on a click event. That query ID closes the loop between what was searched and what was chosen. Without it, every ranking change is a guess validated by anecdotes.
The teams that shape search APIs well tend to share one habit: they treat the query as a document with structure and history, not as a string to be thrown at an index. The API is where that structure lives. Every decision above, from the query grammar to the cursor to the empty-result contract, is really the same decision made in different places: does the contract describe what the user meant, or does it describe what the backend happened to do? A search API that answers the first question will outlast several generations of the index behind it.

