Back to guides
Integration

How to Use Geo Suggest with the Jobs API

CleanJobData Engineering

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, the "only one ID per result" gotcha, 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, pass kinds=city rather 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 ID Per Result, Matching Its kind

Each result carries exactly one filterable ID — whichever field matches its own kind. A city result has city_id, a state result has state_id, a country result has country_id; each row only has the one that applies to it.

[
  {
    "kind": "city",
    "name": "San Francisco",
    "display_label": "San Francisco, California, United States",
    "city_id": 5391959
  },
  {
    "kind": "state",
    "name": "California",
    "display_label": "California, United States",
    "state_id": 5332921
  }
]

For the common case — user picks a result, you filter /jobs by it — this is all you need: whichever kind came back, its one ID is sufficient on its own to filter by that city, state, or country. You don't need the parent hierarchy to filter.

The only time this matters is if your UI wants to display the hierarchy, not just filter by it — e.g. grouping city results under a state label, or showing "California" alongside a picked city. A city result's state_id isn't there for that; use display_label for a ready-made hierarchy string ("San Francisco, California, United States"), since a second lookup just to get a parent ID is rarely worth it. Job objects are a separate case worth knowing about too: their locations array rows do carry city_id, state_id, and country_id together, since a job's normalized location already has its full hierarchy resolved — don't assume Geo Suggest results follow that same shape if you're reusing a type definition from one for the other. Branch on kind when consuming the response:

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 };
  }
}

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:

  1. Always render display_label, not name, in your dropdown. display_label includes the full hierarchy ("Springfield, Illinois, United States") specifically so users can tell results apart; name alone ("Springfield") is ambiguous by design and shouldn't be shown as the only text in a multi-result list.
  2. 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 location ISO2 country-code filter on /jobs if 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 /jobs by raw location text beyond the coarse ISO2 location fallback — resolve through Geo Suggest first.
  • Each result only carries the ID for its own kind — branch on kind, don't assume city_id/state_id/country_id all appear together.
  • Show display_label, not name, so users can disambiguate.
  • Debounce requests and proxy them server-side.
  • On no results, prompt for refinement rather than guessing.