How to Use Geo Suggest with the Jobs API
TL;DR
The /jobs location filter only takes ISO2 country codes — there's no free-text city search — so resolve user input through /geo/suggest first, then filter by the ID matching the chosen result's kind. Every result has the same keys and mirrors a job's locations row, so a picked suggestion can also be stored directly on a job posting to make it filterable. Branch on kind, not on which ID is non-null. Always display display_label, not name, since place names like 'Springfield' or 'Georgia' are ambiguous on their own, and never auto-select a single 'best' match.
If your product lets users search jobs by location, don't filter /jobs with raw free-text location strings — resolve the user's input to a structured ID first with Geo Suggest, then filter with that ID. This guide covers why that matters and the practical details of wiring the two endpoints together: request shape, why kind is the discriminator, storing a picked result on a job posting, disambiguating results, and a real autocomplete implementation pattern.
Why Not Just Filter by Text
The location query param on /jobs only accepts comma-separated ISO 3166-1 alpha-2 country codes (US, GB, DE) — it's a coarse fallback, not a general text search over city/state names. There's no location=San Francisco filter. If you want city- or state-level precision, you need city_id or state_id, and the only reliable way to get those is to resolve the user's typed text through Geo Suggest first. See Job Location Normalization for why raw location strings can't be matched reliably in the first place.
The Request
curl "https://api.cleanjobdata.com/geo/suggest?q=San%20Fran&kinds=city,state&limit=5" \
-H "Authorization: Bearer $CLEANJOBDATA_API_KEY"q(required) — the search text, capped at 200 characters. Matching combines full-text prefix search with fuzzy trigram similarity, so it's tolerant of both partial input ("San Fran"matches San Francisco) and minor typos (a slightly misspelled city name still surfaces close matches) — not just a strict prefix or substring match. This is what makes it usable for autocomplete-as-you-type without needing your own client-side fuzzy-matching layer.kinds— comma-separated filter on result type:city,state,country. Omit to search across all three. Scope this to what your UI actually needs — if your product only supports city-level filtering, passkinds=cityrather than filtering client-side after the fact.limit— max results, default 10, max 30. Keep this small (5-8) for a dropdown-style autocomplete; a long list defeats the purpose of autocomplete.
Results are ranked by text-match relevance first, then by population as a tiebreaker for otherwise-similar matches (larger, more well-known places surface first) — worth knowing if you're wondering why an ambiguous query like "Springfield" returns one particular Springfield before the others.
Response Shape: One Row, Same Keys Every Time
Every result has the same keys regardless of kind — a state result simply has null city fields. The shape deliberately mirrors a row of a job's locations array, so a picked suggestion can be stored straight onto a job posting.
[
{
"kind": "city",
"name": "San Francisco",
"display_label": "San Francisco, California, United States",
"city_id": 5391959,
"city_name": "San Francisco",
"state_id": 5332921,
"state_name": "California",
"state_code": "CA",
"country_id": 233,
"country_name": "United States",
"country_code": "US",
"region": "Americas",
"subregion": "Northern America",
"lat": 37.77493,
"lng": -122.41942,
"timezone": "America/Los_Angeles"
}
]Because the keys are always present, branch on kind, not on which ID is non-null. A city result carries country_id too; filtering /jobs by it would silently widen the search to the whole country:
function toJobsFilter(result: GeoSuggestResult) {
switch (result.kind) {
case "city":
return { city_id: result.city_id };
case "state":
return { state_id: result.state_id };
case "country":
return { country_id: result.country_id };
}
}Posting a Job with a Resolved Location
The same response is what you want when a user is creating a listing rather than searching one. Have them pick a location from the autocomplete and store the result as the job's location row — it already carries the same city_id/state_id/country_id that our location normalizer resolves for ingested jobs, so a posting made this way is filterable by exactly the same /jobs queries as everything else in the index. No parsing, no string matching, no separate reconciliation step.
Practically: keep kind alongside the IDs so you know the precision the user actually chose, and keep display_label for rendering the listing back to them. Skipping the autocomplete and storing a free-text location is what makes a posting unfindable — see Job Location Normalization for why.
Feeding the Result into /jobs
Whichever ID you get back, pass it straight through as the matching /jobs filter — no reformatting needed:
curl "https://api.cleanjobdata.com/jobs?city_id=5391959&title=engineer&remote=true" \
-H "Authorization: Bearer $CLEANJOBDATA_API_KEY"city_id, state_id, and country_id all accept comma-separated values if you want to let users select multiple locations (e.g. "San Francisco or Seattle") — resolve each selection through Geo Suggest independently, then join the resulting IDs before the /jobs call.
Disambiguating Common Names
Many place names aren't unique — "Springfield" exists in a dozen US states, "Georgia" is both a US state and a country. Two practical mitigations:
- Always render
display_label, notname, in your dropdown.display_labelincludes the full hierarchy ("Springfield, Illinois, United States") specifically so users can tell results apart;namealone ("Springfield") is ambiguous by design and shouldn't be shown as the only text in a multi-result list. - Don't auto-select a single "best" result. Even when your query looks unambiguous to you, let the user confirm from the dropdown rather than silently picking the first match — a wrong silent match (filtering by the wrong Springfield) is a worse experience than one extra click.
No Results
If a query returns an empty array, that's a genuine "we don't have a match" — not a signal to fall back to fuzzy client-side matching. Reasonable fallbacks, roughly in order of precision lost:
- Prompt the user to refine their search (most reliable, no accuracy tradeoff).
- Fall back to the
locationISO2 country-code filter on/jobsif you can infer a country from other context (e.g. the user's locale) — coarser, but still structured. - As a last resort, fall back to remote-only results (
remote=true) rather than guessing a location.
Don't fall back to filtering /jobs by matching the raw query text against job titles or descriptions — that reintroduces exactly the unreliable string-matching problem Geo Suggest exists to avoid.
A Debounced Autocomplete Pattern
For a typeahead input, debounce the request (don't fire one per keystroke) and keep the request cheap:
"use client";
import { useState, useEffect } from "react";
function useGeoSuggest(query: string, kinds = "city,state,country") {
const [results, setResults] = useState<GeoSuggestResult[]>([]);
useEffect(() => {
if (query.trim().length < 2) {
setResults([]);
return;
}
const timeout = setTimeout(async () => {
const res = await fetch(
`/api/geo/suggest?q=${encodeURIComponent(query)}&kinds=${kinds}&limit=6`
);
setResults(await res.json());
}, 250); // debounce window
return () => clearTimeout(timeout);
}, [query, kinds]);
return results;
}Route this through a thin server-side proxy (/api/geo/suggest) rather than calling the CleanJobData API directly from the client, for the same reason as any other integration: your API key stays server-side. See Add Job Search to an Existing App for the proxy pattern in full.
A 250ms debounce is a reasonable starting point — short enough to feel responsive, long enough that a fast typist doesn't fire a request per keystroke. Cache resolved selections client-side by query string if users tend to re-search the same locations, since Geo Suggest results for a given q don't change often.
Summary
- Never filter
/jobsby raw location text beyond the coarse ISO2locationfallback — resolve through Geo Suggest first. - Every result has the same keys and carries the full hierarchy, so branch on
kindto pick the right filter — never on which ID happens to be non-null. - Show
display_label, notname, so users can disambiguate. - The response doubles as a job-posting location row — store a picked result to make a new listing filterable by the same
/jobsqueries. - Debounce requests and proxy them server-side.
- On no results, prompt for refinement rather than guessing.