Skip to content

Guide

How to add address autocomplete to a Vue or Nuxt app

A Vue 3 composable and component for address autocomplete with the geolith API, plus the Nuxt runtime config to keep the publishable key out of your source.

By · 12 min · updated

This guide adds address autocomplete to a Vue 3 or Nuxt 4 app with the geolith API. A composable, useAddressAutocomplete, watches the input, waits 200 ms, cancels the previous request and calls GET https://api.geolith.app/v1/autocomplete; a small component renders the list and emits the chosen address with its coordinates. No extra packages are needed.

1. Key and config

Create a publishable key (gc_pk_...) at console.geolith.app and add your origins (for example http://localhost:3000 and your production domain).

In Nuxt, nuxt.config.ts:

export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      // Set NUXT_PUBLIC_GEOLITH_KEY=YOUR_API_KEY in .env
      geolithKey: '',
    },
  },
});

In a plain Vite + Vue app, use VITE_GEOLITH_KEY=YOUR_API_KEY in .env.local and read import.meta.env.VITE_GEOLITH_KEY instead.

2. The composable

composables/useAddressAutocomplete.ts:

import { ref, watch, onScopeDispose, type Ref } from 'vue';

export type AddressSuggestion = {
  gid: string;
  label: string;
  lat: number;
  lon: number;
  street: string;
  locality: string;
  region: string;
  postalcode: string;
  countryCode: string;
};

type Feature = {
  geometry: { coordinates: [number, number] };
  properties: Record<string, string | undefined> & { gid: string; label: string };
};

export function useAddressAutocomplete(
  text: Ref<string>,
  options: { apiKey: string; country?: string; size?: number },
) {
  const suggestions = ref<AddressSuggestion[]>([]);
  const loading = ref(false);
  let timer: ReturnType<typeof setTimeout> | undefined;
  let controller: AbortController | undefined;

  async function run(query: string) {
    controller?.abort();
    controller = new AbortController();
    const url = new URL('https://api.geolith.app/v1/autocomplete');
    url.searchParams.set('text', query);
    url.searchParams.set('size', String(options.size ?? 6));
    if (options.country) url.searchParams.set('boundary.country', options.country);
    url.searchParams.set('api_key', options.apiKey);
    loading.value = true;
    try {
      const res = await fetch(url, { signal: controller.signal });
      if (!res.ok) {
        suggestions.value = [];
        return;
      }
      const data: { features: Feature[] } = await res.json();
      suggestions.value = data.features.map((f) => ({
        gid: f.properties.gid,
        label: f.properties.label,
        lon: f.geometry.coordinates[0],
        lat: f.geometry.coordinates[1],
        street: f.properties.street ?? f.properties.name ?? '',
        locality: f.properties.locality ?? '',
        region: f.properties.region ?? '',
        postalcode: f.properties.postalcode ?? '',
        countryCode: f.properties.country_code ?? '',
      }));
    } catch (err) {
      if ((err as Error).name !== 'AbortError') throw err;
    } finally {
      loading.value = false;
    }
  }

  watch(text, (value) => {
    clearTimeout(timer);
    if (value.trim().length < 3) {
      controller?.abort();
      suggestions.value = [];
      return;
    }
    timer = setTimeout(() => run(value), 200);
  });

  onScopeDispose(() => {
    clearTimeout(timer);
    controller?.abort();
  });

  return { suggestions, loading };
}

3. The component

components/AddressAutocomplete.vue:

<script setup lang="ts">
import { ref } from 'vue';
import {
  useAddressAutocomplete,
  type AddressSuggestion,
} from '~/composables/useAddressAutocomplete';

const props = defineProps<{ country?: string }>(); // e.g. 'IN' or 'FR,BE'; omit for worldwide
const emit = defineEmits<{ select: [address: AddressSuggestion] }>();

const { public: config } = useRuntimeConfig(); // plain Vue: import.meta.env.VITE_GEOLITH_KEY
const text = ref('');
const open = ref(false);
const active = ref(-1);
const { suggestions } = useAddressAutocomplete(text, {
  apiKey: config.geolithKey as string,
  country: props.country,
});

function onInput() {
  open.value = true;
  active.value = -1;
}

function choose(s: AddressSuggestion) {
  open.value = false;
  emit('select', s);
  // Set the text last; the composable ignores it until the user types again.
  text.value = s.label;
}

function onKeydown(e: KeyboardEvent) {
  if (!open.value || suggestions.value.length === 0) return;
  if (e.key === 'ArrowDown') {
    e.preventDefault();
    active.value = Math.min(active.value + 1, suggestions.value.length - 1);
  } else if (e.key === 'ArrowUp') {
    e.preventDefault();
    active.value = Math.max(active.value - 1, 0);
  } else if (e.key === 'Enter' && active.value >= 0) {
    e.preventDefault();
    choose(suggestions.value[active.value]!);
  } else if (e.key === 'Escape') {
    open.value = false;
  }
}
</script>

<template>
  <div class="geo-ac">
    <input
      v-model="text"
      type="text"
      role="combobox"
      autocomplete="off"
      aria-autocomplete="list"
      :aria-expanded="open && suggestions.length > 0"
      placeholder="Start typing an address"
      @input="onInput"
      @keydown="onKeydown"
      @blur="open = false"
    />
    <ul v-if="open && suggestions.length" role="listbox">
      <li
        v-for="(s, i) in suggestions"
        :key="s.gid"
        role="option"
        :aria-selected="i === active"
        :class="{ active: i === active }"
        @mousedown.prevent="choose(s)"
      >
        {{ s.label }}
      </li>
      <li class="attribution">© OpenStreetMap contributors (ODbL), Overture Maps Foundation</li>
    </ul>
  </div>
</template>

<style scoped>
.geo-ac { position: relative; max-width: 32rem; }
.geo-ac input { width: 100%; padding: 0.6rem 0.75rem; font-size: 1rem; }
.geo-ac ul { position: absolute; z-index: 10; inset-inline: 0; margin: 0; padding: 0; list-style: none; background: #fff; border: 1px solid #ccc; }
.geo-ac li { padding: 0.5rem 0.75rem; cursor: pointer; }
.geo-ac li.active { background: #eef7f6; }
.geo-ac .attribution { cursor: default; font-size: 0.7rem; color: #666; }
</style>

Choosing a suggestion sets the input to its label, which changes text and would start a new search. The @input handler only reopens the list when the user types, so the programmatic change fetches once and stays closed. If you want zero extra requests, keep a flag in the composable and skip the next change.

4. Use it

<script setup lang="ts">
import type { AddressSuggestion } from '~/composables/useAddressAutocomplete';

const picked = ref<AddressSuggestion | null>(null);
</script>

<template>
  <form>
    <label>Delivery address</label>
    <AddressAutocomplete @select="picked = $event" />
    <p v-if="picked">
      {{ picked.postalcode }} · {{ picked.lat.toFixed(5) }}, {{ picked.lon.toFixed(5) }}
    </p>
  </form>
</template>

Notes

  • Coordinates in GeoJSON are [longitude, latitude]; the composable maps them to lat and lon so you cannot mix them up.
  • Over quota, the API answers HTTP 429 with quota_exceeded; the composable shows no suggestions instead of failing the form.
  • Coverage is worldwide. Pass country (an ISO code such as IN, or FR,BE) to limit suggestions.

The React version is here.

Questions

Quick answers

Should I call geolith from a Nuxt server route instead?
Not for autocomplete. Calling from the browser with a publishable key saves a hop on every keystroke. Use a server route with a secret key for batch or back-office geocoding.
Does this need any npm packages?
No. It uses fetch, AbortController and Vue's own reactivity.
How do I limit results to one country?
Pass boundary.country with an ISO code such as IN. The composable takes it as an option.

Geocode your first address in a minute.

100,000 requests a month free. Commercial use with attribution. No card.