All components

Autocomplete Location

Async location search input backed by the Mapbase /v1/locations/autocomplete endpoint. Built on shadcn Command + Popover.

Live preview

Set NEXT_PUBLIC_MAPBASE_DEMO_API_KEY in apps/web/.env to load this preview. Use a demo key with allowed_domains scoped to this site.

Install

$bunx shadcn@latest add https://mapbase.dev/r/autocomplete-location.json

The shadcn CLI copies the file(s) below into your tree, installs npm dependencies, and recursively pulls registry dependencies (including from cross-registry URLs).

npm dependencies

mapbase

Registry dependencies

commandpopoverinputbutton

Source

components/mapbase/autocomplete-location.tsx
"use client"

import * as React from "react"
import {
  Mapbase,
  type LocationSuggestion,
  type CountryCode,
  type LocationKind,
  MapbaseError,
} from "mapbase"

import { Button } from "@/components/ui/button"
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandLoading,
} from "@/components/ui/command"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import { cn } from "@/lib/utils"
import { MapPinIcon } from "lucide-react"

export type AutocompleteLocationProps = {
  /**
   * Public Mapbase API key (`mb_live_*`). Mint one at
   * https://dashboard.mapbase.dev/api-keys.
   */
  apiKey: string
  /** Optional: pin the engine URL. Defaults to https://api.mapbase.dev. */
  baseUrl?: string
  /** Restrict suggestions to one country (ISO-3166 alpha-2). */
  country?: CountryCode
  /** Restrict suggestions to a subset of admin levels. */
  kinds?: LocationKind[]
  /** Bias results toward a coordinate. */
  near?: { lng: number; lat: number }
  /** Max suggestions per request. Engine clamps at 10. */
  limit?: number
  /** Debounce window for input → request. Defaults to 200 ms. */
  debounceMs?: number
  /** Min characters before issuing a request. Defaults to 2. */
  minChars?: number
  /** Initial selection (controlled). */
  value?: LocationSuggestion | null
  /** Fired when the user picks (or clears) a suggestion. */
  onChange?: (suggestion: LocationSuggestion | null) => void
  /** Placeholder for the empty input. */
  placeholder?: string
  /** Trigger label when nothing is selected. */
  triggerLabel?: string
  /** Disable the entire control. */
  disabled?: boolean
  /** Layout/sizing classes for the control root (`max-w-*`, margins, etc.). */
  className?: string
}

/**
 * Async location search input backed by the Mapbase
 * `/v1/locations/autocomplete` endpoint.
 *
 * Built on shadcn Command + Popover. Owns its open state and query
 * state; selection is controlled via `value` / `onChange`. Aborts
 * stale requests on every keystroke and surfaces `MapbaseError`s
 * inline so callers don't have to wire toast plumbing for the common
 * case (rate-limit, invalid key, network).
 */
export function AutocompleteLocation({
  apiKey,
  baseUrl,
  country,
  kinds,
  near,
  limit = 8,
  debounceMs = 200,
  minChars = 2,
  value,
  onChange,
  placeholder = "Search a city, district, neighborhood…",
  triggerLabel = "Pick a location",
  disabled,
  className,
}: AutocompleteLocationProps) {
  const [open, setOpen] = React.useState(false)
  const [query, setQuery] = React.useState("")
  const [suggestions, setSuggestions] = React.useState<LocationSuggestion[]>([])
  const [loading, setLoading] = React.useState(false)
  const [error, setError] = React.useState<MapbaseError | null>(null)
  const rootRef = React.useRef<HTMLDivElement>(null)
  const [popoverWidth, setPopoverWidth] = React.useState<number>()

  // Measure the layout root (includes consumer `className` e.g. `max-w-*`),
  // not the viewport-wide parent — so the popover matches the trigger strip.
  React.useLayoutEffect(() => {
    const root = rootRef.current
    if (!root) return

    const sync = () => setPopoverWidth(root.getBoundingClientRect().width)

    sync()
    const ro = new ResizeObserver(sync)
    ro.observe(root)
    return () => ro.disconnect()
  }, [className])

  // Re-create the client only when `apiKey` / `baseUrl` change. Caller
  // typically passes a stable key, so this is effectively once.
  const client = React.useMemo(
    () => new Mapbase(apiKey, baseUrl ? { baseUrl } : undefined),
    [apiKey, baseUrl]
  )

  // Stable serialisation of the bias params so `useEffect` doesn't
  // re-fire on every render of `near={{lng, lat}}` etc.
  const kindsKey = React.useMemo(
    () => (kinds && kinds.length > 0 ? kinds.join(",") : ""),
    [kinds]
  )
  const nearKey = near ? `${near.lng},${near.lat}` : ""

  React.useEffect(() => {
    setError(null)
    if (query.trim().length < minChars) {
      setSuggestions([])
      setLoading(false)
      return
    }

    const controller = new AbortController()
    const timer = window.setTimeout(async () => {
      setLoading(true)
      const result = await client.locations.autocomplete(
        {
          q: query,
          ...(country ? { country } : {}),
          ...(kinds && kinds.length > 0 ? { kinds } : {}),
          ...(near ? { near } : {}),
          limit,
        },
        { signal: controller.signal }
      )
      // The fetch hasn't been aborted (a newer keystroke would have
      // killed it). Safe to commit.
      if (controller.signal.aborted) return

      if (result.error) {
        // Network aborts surface as `network_error`; the AbortController
        // path is the user's own; ignore.
        if (
          result.error.code === "network_error" &&
          controller.signal.aborted
        ) {
          return
        }
        setError(result.error)
        setSuggestions([])
      } else {
        setSuggestions(result.data?.data ?? [])
      }
      setLoading(false)
    }, debounceMs)

    return () => {
      controller.abort()
      window.clearTimeout(timer)
    }
  }, [client, query, minChars, debounceMs, country, kindsKey, nearKey, limit])

  const handleSelect = (suggestion: LocationSuggestion | null) => {
    onChange?.(suggestion)
    setOpen(false)
    setQuery("")
  }

  return (
    <div ref={rootRef} className={cn("w-full min-w-0", className)}>
      <Popover open={open} onOpenChange={setOpen}>
        <PopoverTrigger asChild>
          <Button
            type="button"
            variant="outline"
            role="combobox"
            aria-expanded={open}
            disabled={disabled}
            className={cn(
              "relative w-full justify-between text-left font-normal",
              !value && "pl-8 text-muted-foreground"
            )}
          >
            <MapPinIcon className="absolute top-1/2 left-2 size-4 -translate-y-1/2" />
            {value ? (
              <span className="truncate">
                <span className="font-medium">{value.name}</span>
              </span>
            ) : (
              triggerLabel
            )}
          </Button>
        </PopoverTrigger>
        <PopoverContent
          align="start"
          className="box-border max-w-none p-0"
          style={popoverWidth != null ? { width: popoverWidth } : undefined}
        >
          <Command shouldFilter={false}>
            <CommandInput
              placeholder={placeholder}
              value={query}
              onValueChange={setQuery}
            />
            <CommandList>
              {loading ? <CommandLoading>Searching…</CommandLoading> : null}

              {!loading && error ? (
                <div className="flex flex-col gap-1 p-3 text-sm">
                  <span className="font-medium text-destructive">
                    {humanizeErrorCode(error.code)}
                  </span>
                  <span className="text-muted-foreground">{error.message}</span>
                </div>
              ) : null}

              {!loading && !error && query.trim().length < minChars ? (
                <CommandEmpty>
                  Type at least {minChars} characters.
                </CommandEmpty>
              ) : null}

              {!loading && !error && query.trim().length >= minChars ? (
                <CommandEmpty>No results.</CommandEmpty>
              ) : null}

              {!error && suggestions.length > 0 ? (
                <CommandGroup>
                  {suggestions.map((s) => (
                    <CommandItem
                      key={s.id}
                      value={s.id}
                      onSelect={() => handleSelect(s)}
                      className="flex flex-col items-start gap-0.5"
                    >
                      <span className="font-medium">{s.name}</span>
                      <div className="flex w-full items-end justify-between gap-1">
                        <div className="text-xs text-muted-foreground">
                          {s.kind}
                        </div>
                      </div>
                    </CommandItem>
                  ))}
                </CommandGroup>
              ) : null}

              {value ? (
                <CommandGroup>
                  <CommandItem
                    onSelect={() => handleSelect(null)}
                    className="text-muted-foreground"
                  >
                    Clear selection
                  </CommandItem>
                </CommandGroup>
              ) : null}
            </CommandList>
          </Command>
        </PopoverContent>
      </Popover>
    </div>
  )
}

function humanizeErrorCode(code: MapbaseError["code"]): string {
  switch (code) {
    case "unauthorized":
      return "Invalid API key"
    case "rate_limited":
      return "Rate limited — try again shortly"
    case "invalid_request":
      return "Invalid query"
    case "not_implemented":
      return "Endpoint not yet available"
    case "network_error":
      return "Network error"
    default:
      return "Something went wrong"
  }
}