Nakodo runs outreach campaigns for brands. Most of them are aimed at creators, but a brand can also point one at local businesses: a coffee roaster that wants to be stocked by independent cafes, say. That kind of campaign needs two answers before it can do anything, and the setup form asks them plainly:
What kind of business?
Where?
The public write-up of that is on nakodo.app/for/small-businesses. This post is about the form, which is one component, src/app/(app)/campaigns/targeting-fields.tsx, 371 lines, used by both the setup flow and the brief page.
The two fields look like siblings. They are both a text input with results underneath. They behave in opposite ways, and the reason has nothing to do with how fast either one is.
Field one: ours, so it searches as you type
The types of business start as twelve tiles (cafe, pub, restaurant, hotel, gym, salon and so on), and under them a line that admits the tiles are not the whole world:
<Label htmlFor={id} className="text-[13px] font-normal text-muted-foreground">
Something else? Search every type of business.
</Label>
Behind that input is our own category list, which lives in the repo as a tuple JSON of 2,150 entries. Nothing third party is touched, nothing is metered, and the whole search is a synchronous function over an in-memory Map. So it searches on every keystroke, with the three guards that any as-you-type field needs:
const type = (v: string) => {
setQuery(v);
clearTimeout(timer.current);
request.current?.abort();
const q = v.trim();
if (q.length < 2) return;
timer.current = setTimeout(async () => {
const controller = new AbortController();
request.current = controller;
try {
const res = await fetch(`/api/places/categories?${new URLSearchParams({ q })}`, { signal: controller.signal });
if (!res.ok) throw new Error(`Category search: ${res.status}`);
setResult({ q, matches: (await res.json()) as CategoryMatch[] });
} catch {
if (!controller.signal.aborted) setResult({ q, matches: null });
}
}, 200);
};
A 200ms debounce, an AbortController that a newer keystroke uses to cancel the older request, and a result object that carries the query it answered:
const [result, setResult] = useState<{ q: string; matches: CategoryMatch[] | null }>({ q: "", matches: [] });
const current = result.q === query.trim();
That last line is the part people skip. Aborting is not enough on its own, because an abort is a race you can lose: the response can be in flight and already parsed when the signal fires. Storing the query next to its answer means a late response for "flo" cannot be rendered under "florist". It is rendered only while it is the answer to what is in the box.
matches has three states, not two, and the type says so. An array is an answer, [] is an answer of nothing, and null is a failure:
const failed = current && result.matches === null;
Which is what lets the failure be honest in the UI, with an error that does not pretend the search came back empty:
{query.trim().length >= 2 && failed && (
<p className="text-[13px] text-danger" role="alert">
Search isn't answering. Try again in a moment.
</p>
)}
And the cleanup, which is four lines and prevents two different bugs (a setState after unmount, and a request that outlives the screen it belonged to):
useEffect(
() => () => {
clearTimeout(timer.current);
request.current?.abort();
},
[],
);
Why a route handler and not a Server Action
This is in a Next.js app where nearly every write is a Server Action, so the first question a reviewer asks is why this read is a fetch to /api/places/categories instead. The route's own comment answers it:
// Types of business matching what the user typed, for the business setup's
// search (src/app/(app)/campaigns/targeting-fields.tsx). It runs as they
// type, so it's a GET rather than a Server Action: actions queue one at a
// time, and a read doesn't need to wait behind a save. The categories are our
// own list, so nothing third-party is called.
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" } });
}
Server Actions are serialised per client: React sends one at a time and queues the rest. For a save that is a feature. For a keystroke it means the sixth letter you type is waiting behind an autosave of the field above. A GET also gets to be cached by the browser, which an action never is, and private, max-age=3600 is free correctness here because the list only changes when we ship a new one.
The endpoint is behind the same auth boundary as the rest of the app, which anyone can check from outside:
$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' \
'https://nakodo.app/api/places/categories?q=flor'
307 https://nakodo.app/login?next=%2Fapi%2Fplaces%2Fcategories%3Fq%3Dflor
Field two: not ours, so it waits for a click
The second field asks where. Behind it is Nominatim, the OpenStreetMap geocoder, whose usage policy allows one request per second for a whole application. Not per user. Per application.
You cannot build search-as-you-type on that. A single person typing "Manchester" at a normal speed would spend the entire app's budget for ten seconds. So the field does not search while you type at all:
// Looked up only when asked (Nominatim's rules), never as you type.
const search = () => {
if (query.trim().length < 2 || pending) return;
start(async () => {
const r = await findPlacesAction(query);
setError(r.ok ? null : r.error);
setMatches(r.ok ? r.matches : null);
});
};
No debounce, no AbortController, no stale-answer guard, because there is never more than one request and it was asked for deliberately. useTransition gives the pending state, and the button carries it:
<Button type="button" variant="outline" onClick={search} disabled={pending || query.trim().length < 2}>
{pending ? "Looking..." : "Add"}
</Button>
Enter does the same thing, with the one line that stops Enter submitting the whole setup form instead:
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
search();
}
}}
Here a Server Action is the right call, precisely because actions queue. The global one-per-second limiter on the server side is a fixedWindow(1, "1 s") keyed on a constant, and a caller that arrives early waits rather than failing. An action that queues behind the user's other actions is helping.
The results are not applied automatically either. "Manchester" is a point and a boundary in OSM, so the field asks:
<p className="text-[13px] text-muted-foreground">Which one?</p>
and each candidate shows what picking it would mean, a radius or a whole country:
<span className="text-[12px] text-muted-foreground">{m.kind === "country" ? "Whole country" : `${m.radiusKm} km`}</span>
The attribution sits inside the same fieldset as the input, not in a footer somewhere:
<p className="text-[12px] text-muted-foreground">
Place search © <a href="https://www.openstreetmap.org/copyright" ...>OpenStreetMap contributors</a>.
Business listings from <a href="https://overturemaps.org" ...>Overture Maps</a>.
</p>
Attribution next to the thing it is attributing is also a reminder to the next person editing this file that the field is on somebody else's budget.
The shape the two fields share
Both fields cap what you can add, and both do it by disabling rather than hiding:
export const MAX_TYPES = 8;
export const MAX_LOCATIONS = 10;
disabled={!on && full}
A selected tile stays clickable when the campaign is full, so the way out of the cap is always visible: untick something. Only the unselected ones go dim, and a sentence appears under them saying what the number is:
{full && <p className="text-[13px] text-muted-foreground">One campaign can look for up to {MAX_TYPES} types.</p>}
The locations field does the opposite with its input, which disappears entirely at ten, because an input you cannot type into is worse than no input.
Everything selected is a button with aria-pressed, not a checkbox dressed as a card:
<button key={t.id} type="button" aria-pressed={on} disabled={!on && full} onClick={() => toggle(t.label, t.categories)}>
Both groups are a fieldset with a real legend, both result lists are aria-live="polite" or role="alert" depending on whether the news is good, and every remove button has its own name:
<Button type="button" variant="ghost" size="icon" aria-label={`Remove ${l.label}`} onClick={() => remove(i)}>
One detail I like more than it deserves: a radius of null means the whole country, and the UI does not render a disabled select for it. The row just states the fact.
{l.radiusKm === null ? (
<span className="text-[13px] text-muted-foreground">The whole country</span>
) : (
<Select value={String(l.radiusKm)} onValueChange={(v) => update(i, { radiusKm: Number(v) })}>
The seven radii are a const tuple, so the select cannot offer a value the backend has not reasoned about:
export const RADII_KM = [2, 5, 10, 25, 50, 100, 200] as const;
What writing this up changed my mind about
Two things.
The tile grid dedupes by label, not by category code, because a tile's label is what the brand sees and two tiles will never share one. The free-text search picks by code and then adds { label, categories: [code] }. So a brand that picks the "Cafe" tile and then searches up "coffee shop" gets two entries that overlap underneath. That is harmless (the matching is a set union) but the count against MAX_TYPES is a count of chips, not of categories, and I would rather the cap counted the thing it sounds like it counts.
And the stale-answer guard is written once, in the field that needed it. The other field has no race to lose, so it has no guard, and a reader skimming the file could easily read that as an oversight rather than a decision. The fix is not more code. It is the comment that is already on the other field, which is the single most useful line in the component:
// Looked up only when asked (Nominatim's rules), never as you type.
Two fields, same shape, opposite behaviour, and the rule that decides which is which is simply: is this our list or somebody else's.
Top comments (0)