# 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.

---
title: How to add address autocomplete to a Vue or Nuxt app
description: 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.
date: '2026-10-22'
tags: [vue, nuxt, autocomplete]
minutes: 12
steps:
  - name: Create a publishable key
    text: In console.geolith.app create a publishable key (gc_pk_...) and allow your origins, for example http://localhost:3000.
  - name: Expose the key through runtime config
    text: In Nuxt, set runtimeConfig.public.geolithKey from NUXT_PUBLIC_GEOLITH_KEY. In plain Vue with Vite, use import.meta.env.VITE_GEOLITH_KEY.
  - name: Add the useAddressAutocomplete composable
    text: It debounces input by 200 ms, cancels stale requests with AbortController and calls /v1/autocomplete.
  - name: Add the AddressAutocomplete component
    text: It renders the input and the suggestion list and emits select with the chosen address.
  - name: Show attribution
    text: Keep the attribution line under the suggestion list.
faqs:
  - q: Should I call geolith from a Nuxt server route instead?
    a: 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.
  - q: Does this need any npm packages?
    a: No. It uses fetch, AbortController and Vue's own reactivity.
  - q: How do I limit results to one country?
    a: Pass boundary.country with an ISO code such as IN. The composable takes it as an option.
---

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](https://console.geolith.app/signup) and add your origins (for example `http://localhost:3000` and your production domain).

In Nuxt, `nuxt.config.ts`:

```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`:

```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`:

```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

```vue
<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](/guides/address-autocomplete-react).


Get a free API key: https://console.geolith.app/signup
