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 tolatandlonso 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 asIN, orFR,BE) to limit suggestions.
The React version is here.