Building an intelligent, location-aware search experience can be very complex—it requires wrangling coordinates from the CMS, securing API keys, and stitching together server-side search logic with a responsive frontend map. Generic search results are no longer enough; your users demand answers that are hyper-local and AI-compatible.
This step-by-step guide will walk you through an existing demo, detailing a headless, geo-aware search experience built with Nuxt 3, ACF (Advanced Custom Fields), its Google Map field (for latitude/longitude data), and Smart Search AI’s geo filtering API.
You’ll learn how to use a Google Map to establish a search center point—which can be set via an address input, a map click, or browser geolocation—and then apply a miles radius to return relevant results. By the end of this article, you’ll have a Nuxt starter that performs location searches against your WordPress ACF data and renders interactive markers on the map.
Table of Contents
Prerequisites
To benefit from this article, you should be familiar with the basics of working with the command line, headless WordPress development, Nuxt.js, and the WP Engine User Portal.
Steps for setting up:
1. Set up an account on WP Engine and get a WordPress install running. Log in to your WP Admin.
Headless
Platform
The all-in-one platform for
radically fast headless sites.
2. Add a Smart Search license. Refer to the docs here to add a license.
3. In the WP Admin, go to WP Engine Smart Search > Settings. You will find your Smart Search GraphQL endpoint and access token here. Copy and save it. We will need it for our environment variables for the frontend. You should see this page:

4. Go to Plugins in your WP Admin page and search for “ACF”. Click on “ACF” and then download the plugin. Once downloaded, activate it.

Now that we have ACF installed, we can make our custom post type and custom fields for our locations. For this example, I added random BBQ restaurants and a bar in Austin, Texas.
Advanced custom fields to build
with WordPress your way.
5. Before we use the Google Map ACF field, we need to register our Google Map API key to WordPress. Here are the docs from ACF on how to do this: https://www.advancedcustomfields.com/resources/google-map/#requirements. Save that API key because we will need it for the frontend as well.
In this example, I added my Google Map API key to my theme’s functions.php file.
6. Click on ACF on the side menu. It will give you menu options. Next, click on Post Types. You will see the page below. Let’s make a post type called “Locations”. Go ahead and fill in the necessary fields to create it. You can leave the default settings as they are once you fill in the fields.

7. Next, let’s add the custom fields that will live in our locations custom post type. Click on Field Groups from the ACF side menu. You can name the field group “Location Details.” It will take you to the create field groups page. Click on the Add New button. It will give you some fields to fill out.
8. The first field we will make is the “Address” field. This will be a text field type. The field label and name will be “address”. Set its post type equal to Location.
9. The second field is where our geo coordinates go. Create a field called “location”. This field type will be a Google Map. Select it from the field menu. The label and name will both be “location”. For the default location of the map, you can put whatever you like. For this example, I put the coordinates of Austin, TX. Make sure this one is also equal to the location Post type as well.
These will be our two custom fields and it will be under the Location Details field group:

10. Next, navigate to the WP Engine AI Toolkit option in the side menu. We need to configure our search model. Go to Configuration. Select the Hybrid card. Add the post_content, post_title, and locationDetails.address fields in the Hybrid settings section. We are going to use these fields as our AI-powered field for hybrid searches. Make sure to hit Save Configuration afterward.


11. Now, we need to weight the fields that we want Smart Search AI to prioritize. Scroll down to the relevancy sliders and put some weight on the post_content, post_title, locationDetails.address and locationDetails.location. This will tell our search what to prioritize when our users search for locations.


12. After saving the configuration, head on over to the Index data page, then click “Index Now”. It will give you this success message once completed :

13. If you want to test it out to make sure you are getting the geolocation coordinates for latitude and longitude data from WordPress, you can run a cURL command against the GraphQL endpoint with the access token that SSAI gives you. Here is a command you can copy. Once you swap out your search endpoint and access token in the command, you can paste it in your terminal:
curl -X POST "$SEARCH_ENDPOINT" \(swap out your endpoint here)
-H "Content-Type: application/json" \
-H "Authorization: Bearer $SEARCH_ACCESS_TOKEN" \(swap your token here)
-d '{
"query": "query FindNearCircle($query: String!, $centerLat: Float!, $centerLon: Float!, $maxDistance: Distance!, $limit: Int) { find(query: $query, semanticSearch: { searchBias: 7, fields: [\"post_title\", \"post_content\", \"locationDetails.address\"] }, geoConstraints: { circles: [{ center: { lat: $centerLat, lon: $centerLon }, maxDistance: $maxDistance }] }, orderBy: [{ field: \"_score\", direction: desc }, { field: \"post_date_gmt\", direction: desc }], limit: $limit, options: { includeFields: [\"post_title\", \"coordinates\", \"locationDetails.coordinates\", \"locationDetails.address\", \"permalink\"] }) { total documents { id sort data } } }",
"variables": {
"query": "*",
"centerLat": 30.2672,
"centerLon": -97.7431,
"maxDistance": "10mi",
"limit": 3
}
}'
Code language: PHP (php)
14. We need to set the frontend up now. The Nuxt.js frontend boilerplate will contain a project that already renders a page with a map and some location filters. Clone the Nuxt repo starting point by copying and pasting this command in your terminal:
npx degit Fran-A-Dev/smart-searchai-geo-filtering-nuxt#main
Code language: PHP (php)
Once you clone it, navigate into the directory and install the project dependencies:
cd my-project
npm install
15. Create a .env file inside the root of the Nuxt project. Open that file and paste in these environment variables (the ones you saved from steps 3 and 5) :
SEARCH_ENDPOINT="<your ssai graphql endpoint here>"
SEARCH_ACCESS_TOKEN="<your smart search ai access token here>"
GOOGLE_MAPS_API_KEY="<your google maps api key here>"
Code language: HTML, XML (xml)
16. Next, let’s update how our Nuxt app will build and run the site. Go to your nuxt.config.ts file in the root and update it accordingly:
// nuxt.config.ts
export default defineNuxtConfig({
compatibilityDate: "2025-10-17",
modules: ["@nuxtjs/tailwindcss"],
css: ["~/assets/css/main.css"],
runtimeConfig: {
// Server-only (private) values
searchAccessToken: process.env.SEARCH_ACCESS_TOKEN,
searchEndpoint: process.env.SEARCH_ENDPOINT,
// Public values available on client
public: {
googleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY,
},
},
devtools: { enabled: true },
nitro: {
experimental: {
websocket: false,
},
// Note: no `fetch` key here—set timeout/retry per-request in $fetch options.
},
});
Code language: JavaScript (javascript)
We are done with the setup steps to create the boilerplate starting point. In your terminal, run npm run dev and visit http://localhost:3000/geo-search to make sure it works. You should see this:

Maps Usage
You’re not locked into any single map library for the UI. This demo uses the Google Maps JavaScript API, but you can swap in Mapbox GL JS, MapLibre GL, Leaflet, or any other JavaScript map component that can display markers from { lat, lon } pairs and emit click/drag events.
The Smart Search piece is library-agnostic: your page just needs a center point (lat/lon) and a radius or bounds to construct the geoConstraints in the GraphQL query. If your chosen map exposes the current bounds, you can also power a bounding-box search; if it supports geolocation, you can seed the center from the user’s position. The only UI changes are in your map wrapper (marker rendering, event wiring); the server call and GraphQL stay the same.
On the WordPress side, you can also use any approach that yields clean latitude/longitude data. ACF fields, a dedicated map plugin, or a custom meta box are all fine—as long as you can persist numeric lat and lon for each location and expose them via REST or GraphQL (WPGraphQL, custom REST fields, or a small plugin).
For Smart Search’s geo filters to work, ensure those values land in your index as a top-level coordinates field with the shape { lat: number, lon: number } (or an array of such objects). If a map plugin stores coordinates under a different key or nested structure, normalize them during indexing (e.g., via a transform hook or a tiny MU/regular plugin) so the index has coordinates at the top level.
Server Connection
The first thing we need to do is to go over the server endpoint that will be our secure proxy for SSAI and the client. Navigate to server/api/search.post.ts. You will see this file:
// server/api/search.post.ts
import {
defineEventHandler,
readBody,
createError,
setResponseStatus,
} from "h3";
type GraphQLBody = {
query?: string;
variables?: Record<string, any>;
};
type GraphQLResponse<T = unknown> = {
data?: T;
errors?: unknown;
};
export default defineEventHandler(async (event) => {
const { searchEndpoint, searchAccessToken } = useRuntimeConfig();
if (!searchEndpoint || !searchAccessToken) {
throw createError({
statusCode: 500,
statusMessage: "Smart Search not configured",
});
}
const body = await readBody<GraphQLBody>(event);
if (!body?.query || typeof body.query !== "string") {
throw createError({
statusCode: 400,
statusMessage: "Missing GraphQL query",
});
}
try {
const resp = await $fetch<GraphQLResponse>(searchEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${searchAccessToken}`,
},
// Ensure variables is always an object (some servers reject undefined)
body: { query: body.query, variables: body.variables ?? {} },
});
// If GraphQL returned errors, surface them with a 502 so the client can show detail
if (resp?.errors) {
setResponseStatus(event, 502);
return resp;
}
return resp; // { data }
} catch (err: any) {
// Log enough for debugging without leaking secrets
console.error("Smart Search API Error", {
status: err?.status,
statusText: err?.statusText,
message: err?.message,
data: err?.data,
});
throw createError({
statusCode: err?.status || 502,
statusMessage: "Smart Search request failed",
data: err?.data ?? null,
});
}
});
Code language: JavaScript (javascript)
This code reads the Smart Search endpoint and access token from the Nuxt runtime config and fails with a 500 if either is missing.
It then parses the incoming request body and validates that a GraphQL query string is present, otherwise returning a 400. The handler forwards the request to Smart Search using $fetch, always sending a proper JSON GraphQL payload with the Bearer token and a guaranteed variables object.
By centralizing the HTTP call here, every client in your app can POST to /api/search with a consistent contract. If Smart Search AI responds with a GraphQL errors array, the endpoint sets an HTTP 502 and returns the error payload untouched for debugging.
On success, it simply relays the upstream { data } to the caller. Operational failures—network issues, timeouts, upstream 5xx—are logged with safe metadata and rethrown as a structured h3 error.
This pattern improves security, avoids CORS complications, and keeps your access token server-only.
GraphQL Queries
The next file we will go over is our GraphQL queries. Head to graphql/queries.ts and open up the file:
// graphql/queries.ts
/** Fields we want back from Smart Search documents. Adjust to your index shape. */
export const DEFAULT_INCLUDE_FIELDS = [
"post_title",
"address", // top-level if you mapped it during indexing
"coordinates", // top-level geo field that Smart Search uses
"post_url", // or "permalink" if your index uses that key
] as const;
/** Compose an optional semanticSearch block only if enabled. */
export const SEMANTIC_BLOCK = `
$semanticBias: Int = 0
$semanticFields: [String!] = []
` as const;
export const SEMANTIC_ARG = `
semanticSearch: { searchBias: $semanticBias, fields: $semanticFields }
` as const;
/** ---------- 1) Circle (nearby) search with optional semantic & pagination ---------- */
export const FIND_NEAR_CIRCLE = /* GraphQL */ `
query FindNearCircle(
$query: String!
$centerLat: Float!
$centerLon: Float!
$maxDistance: Distance!
$limit: Int = 20
$searchAfter: [String!]
$filter: String
$includeFields: [String!] = []
${SEMANTIC_BLOCK}
) {
find(
query: $query
${SEMANTIC_ARG}
filter: $filter
geoConstraints: {
circles: [
{ center: { lat: $centerLat, lon: $centerLon }, maxDistance: $maxDistance }
]
}
orderBy: [
{ field: "_score", direction: desc }
{ field: "post_date_gmt", direction: desc }
]
limit: $limit
searchAfter: $searchAfter
options: { includeFields: $includeFields }
) {
total
documents {
id
score
sort
data
}
}
}
`;
/** ---------- 2) Bounding-box search with optional semantic & pagination ---------- */
export const FIND_IN_BBOX = /* GraphQL */ `
query FindInBoundingBox(
$query: String!
$swLat: Float!
$swLon: Float!
$neLat: Float!
$neLon: Float!
$limit: Int = 20
$searchAfter: [String!]
$filter: String
$includeFields: [String!] = []
${SEMANTIC_BLOCK}
) {
find(
query: $query
${SEMANTIC_ARG}
filter: $filter
geoConstraints: {
boundingBoxes: [
{ southwest: { lat: $swLat, lon: $swLon }, northeast: { lat: $neLat, lon: $neLon } }
]
}
orderBy: [
{ field: "_score", direction: desc }
{ field: "post_date_gmt", direction: desc }
]
limit: $limit
searchAfter: $searchAfter
options: { includeFields: $includeFields }
) {
total
documents {
id
score
sort
data
}
}
}
`;
/** ---------- Helper types for DX ---------- */
export interface FindNearCircleVars {
query: string;
centerLat: number;
centerLon: number;
maxDistance: string; // Distance! scalar, e.g. "5mi", "2km"
limit?: number;
searchAfter?: string[];
filter?: string; // e.g., "post_type:location"
includeFields?: string[];
semanticBias?: number; // 0..10
semanticFields?: string[]; // ["post_title", "post_content"] etc., if configured
}
export interface FindInBBoxVars
extends Omit<FindNearCircleVars, "centerLat" | "centerLon" | "maxDistance"> {
swLat: number;
swLon: number;
neLat: number;
neLon: number;
}
/** Normalize coordinates to an array of points for mapping. */
export type Point = { lat: number; lon: number };
export function normalizeCoordinates(raw: unknown): Point[] {
if (!raw) return [];
if (Array.isArray(raw)) {
return raw
.map((p) => (p && typeof p === "object" ? (p as any) : null))
.filter(Boolean)
.filter((p) => typeof p.lat === "number" && typeof p.lon === "number");
}
if (typeof raw === "object" && raw !== null) {
const p = raw as any;
if (typeof p.lat === "number" && typeof p.lon === "number") {
return [p as Point];
}
}
return [];
}
Code language: HTML, XML (xml)
This module centralizes the GraphQL queries and helpers your Nuxt app uses to perform geo-aware searches against Smart Search AI. This is a lot of code. Let’s break it down.
It declares a DEFAULT_INCLUDE_FIELDS array so you can consistently request the minimal document fields you need back—titles, address, a top-level coordinates geo field, and a URL.
export const DEFAULT_INCLUDE_FIELDS = [
"post_title",
"address", // top-level if you mapped it during indexing
"coordinates", // top-level geo field that Smart Search uses
"post_url", // or "permalink" if your index uses that key
] as const;
Code language: JavaScript (javascript)
It introduces a small semantic-search stanza (SEMANTIC_BLOCK and SEMANTIC_ARG) that can be injected into queries, letting you toggle semantic bias and fields without duplicating query text.
The first query, FIND_NEAR_CIRCLE, searches within a circle by passing a center latitude/longitude and a Distance! scalar (e.g., “5mi”), and supports optional filters (like post_type:location), semantic options, result limits, and cursor pagination via searchAfter.
export const SEMANTIC_BLOCK = `
$semanticBias: Int = 0
$semanticFields: [String!] = []
` as const;
export const SEMANTIC_ARG = `
semanticSearch: { searchBias: $semanticBias, fields: $semanticFields }
` as const;
/** ---------- 1) Circle (nearby) search with optional semantic & pagination ---------- */
export const FIND_NEAR_CIRCLE = /* GraphQL */ `
query FindNearCircle(
$query: String!
$centerLat: Float!
$centerLon: Float!
$maxDistance: Distance!
$limit: Int = 20
$searchAfter: [String!]
$filter: String
$includeFields: [String!] = []
${SEMANTIC_BLOCK}
) {
find(
query: $query
${SEMANTIC_ARG}
filter: $filter
geoConstraints: {
circles: [
{ center: { lat: $centerLat, lon: $centerLon }, maxDistance: $maxDistance }
]
}
orderBy: [
{ field: "_score", direction: desc }
{ field: "post_date_gmt", direction: desc }
]
limit: $limit
searchAfter: $searchAfter
options: { includeFields: $includeFields }
) {
total
documents {
id
score
sort
data
}
}
}
`;
Code language: PHP (php)
The second query, FIND_IN_BBOX, performs the same search semantics within a bounding box using southwest and northeast corners. Both queries sort primarily by _score and secondarily by post_date_gmt to keep results relevant and time-sensible. Each query accepts an includeFields list, which is forwarded to the options.includeFields parameter so you can control payload size per request.
export const FIND_IN_BBOX = /* GraphQL */ `
query FindInBoundingBox(
$query: String!
$swLat: Float!
$swLon: Float!
$neLat: Float!
$neLon: Float!
$limit: Int = 20
$searchAfter: [String!]
$filter: String
$includeFields: [String!] = []
${SEMANTIC_BLOCK}
) {
find(
query: $query
${SEMANTIC_ARG}
filter: $filter
geoConstraints: {
boundingBoxes: [
{ southwest: { lat: $swLat, lon: $swLon }, northeast: { lat: $neLat, lon: $neLon } }
]
}
orderBy: [
{ field: "_score", direction: desc }
{ field: "post_date_gmt", direction: desc }
]
limit: $limit
searchAfter: $searchAfter
options: { includeFields: $includeFields }
) {
total
documents {
id
score
sort
data
}
}
}
`;
Code language: PHP (php)
The file defines TypeScript interfaces (FindNearCircleVars and FindInBBoxVars) that describe the expected variables, including the Distance! value expressed as a string and optional semantic parameters.
export interface FindNearCircleVars {
query: string;
centerLat: number;
centerLon: number;
maxDistance: string; // Distance! scalar, e.g. "5mi", "2km"
limit?: number;
searchAfter?: string[];
filter?: string; // e.g., "post_type:location"
includeFields?: string[];
semanticBias?: number; // 0..10
semanticFields?: string[]; // ["post_title", "post_content"] etc., if configured
}
export interface FindInBBoxVars
extends Omit<FindNearCircleVars, "centerLat" | "centerLon" | "maxDistance"> {
swLat: number;
swLon: number;
neLat: number;
neLon: number;
}
Code language: JavaScript (javascript)
A small utility, normalizeCoordinates, normalizes either a single point or an array of points into a consistent {lat, lon}[] shape, which simplifies rendering map markers.
export type Point = { lat: number; lon: number };
export function normalizeCoordinates(raw: unknown): Point[] {
if (!raw) return [];
if (Array.isArray(raw)) {
return raw
.map((p) => (p && typeof p === "object" ? (p as any) : null))
.filter(Boolean)
.filter((p) => typeof p.lat === "number" && typeof p.lon === "number");
}
if (typeof raw === "object" && raw !== null) {
const p = raw as any;
if (typeof p.lat === "number" && typeof p.lon === "number") {
return [p as Point];
}
}
return [];
}
Google Map Component
Now, let’s take a look at our Map Component. Head to components/MapView.client.vue:
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, watch, toRefs, nextTick } from "vue";
type LatLon = { lat: number; lon: number };
type Marker = LatLon;
const props = defineProps<{
center: LatLon; // { lat, lon }
markers: Marker[]; // search results
userLocation: LatLon | null; // optional blue dot
}>();
const emit = defineEmits<{
(
e: "boundsChanged",
bbox: { swLat: number; swLon: number; neLat: number; neLon: number },
userInitiated: boolean
): void;
(e: "mapClick", location: LatLon): void;
}>();
const { center, markers, userLocation } = toRefs(props);
const mapDiv = ref<HTMLDivElement | null>(null);
const config = useRuntimeConfig();
let map: google.maps.Map | null = null;
let resultMarkers: google.maps.Marker[] = [];
let userMarker: google.maps.Marker | null = null;
let userInitiatedMove = false;
let idleListener: google.maps.MapsEventListener | null = null;
let dragListener: google.maps.MapsEventListener | null = null;
let zoomListener: google.maps.MapsEventListener | null = null;
let clickListener: google.maps.MapsEventListener | null = null;
/** Simple debounce to quiet idle emissions */
function debounce<T extends (...args: any[]) => void>(fn: T, ms = 150) {
let t: number | undefined;
return (...args: Parameters<T>) => {
if (t) window.clearTimeout(t);
t = window.setTimeout(() => fn(...args), ms);
};
}
/** Load Google Maps JS once */
function loadGoogleMaps(): Promise<void> {
return new Promise((resolve, reject) => {
if ((globalThis as any).google?.maps) return resolve();
const key = config.public.googleMapsApiKey;
if (!key) return reject(new Error("Missing GOOGLE_MAPS_API_KEY"));
const script = document.createElement("script");
// v=weekly per Google guidance; only `places` is a recognized library here
script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(
key
)}&libraries=places&v=weekly`;
script.async = true;
script.defer = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error("Failed to load Google Maps JS"));
document.head.appendChild(script);
});
}
function clearResultMarkers() {
for (const m of resultMarkers) m.setMap(null);
resultMarkers = [];
}
function setResultMarkers(list: Marker[]) {
if (!map) return;
clearResultMarkers();
const bounds = new google.maps.LatLngBounds();
let hasAny = false;
for (const m of list) {
if (typeof m.lat !== "number" || typeof m.lon !== "number") continue;
const marker = new google.maps.Marker({
position: { lat: m.lat, lng: m.lon },
title: "Search result",
map,
});
resultMarkers.push(marker);
bounds.extend(new google.maps.LatLng(m.lat, m.lon));
hasAny = true;
}
// If no user-initiated move, fit the map to the results on fresh updates
if (hasAny && !userInitiatedMove) {
// If a single result, ensure a sensible zoom
if (resultMarkers.length === 1) {
map.setCenter({ lat: list[0].lat, lng: list[0].lon });
map.setZoom(Math.max(map.getZoom() || 11, 13));
} else {
map.fitBounds(bounds, 40); // 40px padding
}
}
}
function setUserLocationMarker(loc: LatLon | null) {
if (!map) return;
if (userMarker) {
userMarker.setMap(null);
userMarker = null;
}
if (!loc) return;
userMarker = new google.maps.Marker({
position: { lat: loc.lat, lng: loc.lon },
map,
title: "Your location",
icon: {
path: google.maps.SymbolPath.CIRCLE,
scale: 10,
fillColor: "#4285F4",
fillOpacity: 1,
strokeColor: "#FFFFFF",
strokeWeight: 3,
},
});
}
const emitBoundsChanged = debounce(() => {
if (!map) return;
const b = map.getBounds();
if (!b) return;
const sw = b.getSouthWest();
const ne = b.getNorthEast();
emit(
"boundsChanged",
{ swLat: sw.lat(), swLon: sw.lng(), neLat: ne.lat(), neLon: ne.lng() },
userInitiatedMove
);
userInitiatedMove = false;
}, 150);
async function initMap() {
await nextTick();
const el = mapDiv.value;
if (!el) return;
await loadGoogleMaps();
map = new google.maps.Map(el, {
center: { lat: center.value.lat, lng: center.value.lon },
zoom: 11,
mapTypeControl: true,
streetViewControl: false,
fullscreenControl: true,
});
clickListener = map.addListener("click", (e: google.maps.MapMouseEvent) => {
if (!e.latLng) return;
emit("mapClick", { lat: e.latLng.lat(), lon: e.latLng.lng() });
});
dragListener = map.addListener("dragstart", () => {
userInitiatedMove = true;
});
zoomListener = map.addListener("zoom_changed", () => {
userInitiatedMove = true;
});
idleListener = map.addListener("idle", emitBoundsChanged);
// Initial render
setResultMarkers(markers.value);
setUserLocationMarker(userLocation.value);
}
onMounted(initMap);
onBeforeUnmount(() => {
if (idleListener) idleListener.remove();
if (dragListener) dragListener.remove();
if (zoomListener) zoomListener.remove();
if (clickListener) clickListener.remove();
clearResultMarkers();
if (userMarker) userMarker.setMap(null);
map = null;
});
watch(center, (c) => {
if (!map || !c) return;
map.setCenter({ lat: c.lat, lng: c.lon });
});
watch(
markers,
(list) => {
setResultMarkers(list);
},
{ deep: true }
);
watch(userLocation, (loc) => {
setUserLocationMarker(loc);
});
</script>
<template>
<div
ref="mapDiv"
class="h-80 w-full rounded-xl border"
role="region"
aria-label="Results map"
/>
</template>
Code language: HTML, XML (xml)
This client-side Vue component encapsulates all Google Maps rendering and interaction for the geo search page. It accepts three props—center (the current lat/lon to focus), markers (result points to plot), and an optional userLocation—and emits two events: boundsChanged (with the current SW/NE bounding box and whether the user moved the map) and mapClick (with the clicked lat/lon).
const props = defineProps<{
center: LatLon; // { lat, lon }
markers: Marker[]; // search results
userLocation: LatLon | null; // optional blue dot
}>();
const emit = defineEmits<{
(
e: "boundsChanged",
bbox: { swLat: number; swLon: number; neLat: number; neLon: number },
userInitiated: boolean
): void;
(e: "mapClick", location: LatLon): void;
}>();
Code language: JavaScript (javascript)
On mount, it lazily loads the Google Maps JS SDK using the public API key from our Nuxt runtime config, then initializes a map centered on the provided center with UI controls enabled. A small debounced handler throttles the high-volume idle event so your app isn’t spammed with bounds updates while the user pans/zooms.
const { center, markers, userLocation } = toRefs(props);
const mapDiv = ref<HTMLDivElement | null>(null);
const config = useRuntimeConfig();
let map: google.maps.Map | null = null;
let resultMarkers: google.maps.Marker[] = [];
let userMarker: google.maps.Marker | null = null;
let userInitiatedMove = false;
let idleListener: google.maps.MapsEventListener | null = null;
let dragListener: google.maps.MapsEventListener | null = null;
let zoomListener: google.maps.MapsEventListener | null = null;
let clickListener: google.maps.MapsEventListener | null = null;
/** Simple debounce to quiet idle emissions */
function debounce<T extends (...args: any[]) => void>(fn: T, ms = 150) {
let t: number | undefined;
return (...args: Parameters<T>) => {
if (t) window.clearTimeout(t);
t = window.setTimeout(() => fn(...args), ms);
};
}
/** Load Google Maps JS once */
function loadGoogleMaps(): Promise<void> {
return new Promise((resolve, reject) => {
if ((globalThis as any).google?.maps) return resolve();
const key = config.public.googleMapsApiKey;
if (!key) return reject(new Error("Missing GOOGLE_MAPS_API_KEY"));
Code language: JavaScript (javascript)
The result markers are fully managed: existing pins are cleared before new ones are added, map bounds are fitted to the latest results, and a single-result case bumps the zoom to a useful level. A separate “blue dot” marker is maintained for userLocation, replacing the previous one whenever the prop changes.
The component tracks whether the user moved the map (userInitiatedMove) by listening to dragstart and zoom_changed, and forwards that flag with boundsChanged.
function clearResultMarkers() {
for (const m of resultMarkers) m.setMap(null);
resultMarkers = [];
}
function setResultMarkers(list: Marker[]) {
if (!map) return;
clearResultMarkers();
const bounds = new google.maps.LatLngBounds();
let hasAny = false;
for (const m of list) {
if (typeof m.lat !== "number" || typeof m.lon !== "number") continue;
const marker = new google.maps.Marker({
position: { lat: m.lat, lng: m.lon },
title: "Search result",
map,
});
resultMarkers.push(marker);
bounds.extend(new google.maps.LatLng(m.lat, m.lon));
hasAny = true;
}
// If no user-initiated move, fit the map to the results on fresh updates
if (hasAny && !userInitiatedMove) {
// If a single result, ensure a sensible zoom
if (resultMarkers.length === 1) {
map.setCenter({ lat: list[0].lat, lng: list[0].lon });
map.setZoom(Math.max(map.getZoom() || 11, 13));
} else {
map.fitBounds(bounds, 40); // 40px padding
}
}
}
function setUserLocationMarker(loc: LatLon | null) {
if (!map) return;
if (userMarker) {
userMarker.setMap(null);
userMarker = null;
}
if (!loc) return;
userMarker = new google.maps.Marker({
position: { lat: loc.lat, lng: loc.lon },
map,
title: "Your location",
icon: {
path: google.maps.SymbolPath.CIRCLE,
scale: 10,
fillColor: "#4285F4",
fillOpacity: 1,
strokeColor: "#FFFFFF",
strokeWeight: 3,
},
});
}
const emitBoundsChanged = debounce(() => {
if (!map) return;
const b = map.getBounds();
if (!b) return;
const sw = b.getSouthWest();
const ne = b.getNorthEast();
emit(
"boundsChanged",
{ swLat: sw.lat(), swLon: sw.lng(), neLat: ne.lat(), neLon: ne.lng() },
userInitiatedMove
);
userInitiatedMove = false;
}, 150);
Code language: JavaScript (javascript)
Clicks on the map surface emit precise coordinates so the parent can re-center and re-query. Prop watchers keep the map in sync with application state: updating the center recenters the map, updating the markers re-renders pins and optionally refits the viewport, and updating userLocation refreshes the blue dot.
async function initMap() {
await nextTick();
const el = mapDiv.value;
if (!el) return;
await loadGoogleMaps();
map = new google.maps.Map(el, {
center: { lat: center.value.lat, lng: center.value.lon },
zoom: 11,
mapTypeControl: true,
streetViewControl: false,
fullscreenControl: true, });
clickListener = map.addListener("click", (e: google.maps.MapMouseEvent) => {
if (!e.latLng) return;
emit("mapClick", { lat: e.latLng.lat(), lon: e.latLng.lng() });
});
dragListener = map.addListener("dragstart", () => {
userInitiatedMove = true;
});
zoomListener = map.addListener("zoom_changed", () => {
userInitiatedMove = true;
});
idleListener = map.addListener("idle", emitBoundsChanged);
Code language: JavaScript (javascript)
Finally, it performs a cleanup on unmount by removing Google Maps listeners, clearing markers, and nulling references to prevent leaks. The template exposes a single, accessible container that your layout can size with Tailwind.
// Initial render
setResultMarkers(markers.value);
setUserLocationMarker(userLocation.value);
}
onMounted(initMap);
onBeforeUnmount(() => {
if (idleListener) idleListener.remove();
if (dragListener) dragListener.remove();
if (zoomListener) zoomListener.remove();
if (clickListener) clickListener.remove();
clearResultMarkers();
if (userMarker) userMarker.setMap(null);
map = null;
});
watch(center, (c) => {
if (!map || !c) return;
map.setCenter({ lat: c.lat, lng: c.lon });
});
watch(
markers,
(list) => {
setResultMarkers(list);
},
{ deep: true }
);
watch(userLocation, (loc) => {
setUserLocationMarker(loc);
});
</script>
<template>
<div
ref="mapDiv"
class="h-80 w-full rounded-xl border"
role="region"
aria-label="Results map"
/>
</template>
Code language: HTML, XML (xml)
Feature Map Page
We have one more file to go over before testing this out in the browser. It is the page that will render our map and filters.
Just a note: You can put the logic and state in this file into a separate file within a composables folder. This keeps the code cleaner and more reusable. Since this is just an example demo, I put it all in one file.
Head over to pages/geo-search.vue :
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { FIND_NEAR_CIRCLE, DEFAULT_INCLUDE_FIELDS } from "~/graphql/queries";
type LatLon = { lat: number; lon: number };
type Doc = { id: string; score?: number; sort?: string[]; data: any };
const query = ref("");
const addressQuery = ref("");
const miles = ref(10);
const center = ref<LatLon>({ lat: 30.2672, lon: -97.7431 }); // Austin
const userLocation = ref<LatLon | null>(null);
const docs = ref<Doc[]>([]);
const total = ref(0);
const cursor = ref<string[] | null>(null);
const loading = ref(false);
const geocoding = ref(false);
const hasSearched = ref(false);
let searchToken = 0;
/** Smart Search variables */
const maxDistance = computed(() => `${miles.value}mi`);
const FILTER = "post_type:location";
/** Normalize coordinates field that may be object or array */
function normalizeCoordinates(raw: unknown): LatLon | null {
if (!raw) return null;
const v = Array.isArray(raw) ? raw[0] : raw;
if (
v &&
typeof v === "object" &&
typeof (v as any).lat === "number" &&
typeof (v as any).lon === "number"
) {
const { lat, lon } = v as any;
return { lat, lon };
}
return null;
}
/** Resolve doc -> LatLon for markers */
function docCoordinates(d: Doc): LatLon | null {
// Prefer top-level "coordinates" that Smart Search uses for geo filters
return (
normalizeCoordinates(d?.data?.coordinates) ??
// fallback if you still return nested shape (not required)
normalizeCoordinates(d?.data?.locationDetails?.coordinates) ??
null
);
}
/** Markers for the map */
const markers = computed(() =>
docs.value
.map(docCoordinates)
.filter((c): c is LatLon => !!c)
.map((c) => ({ lat: c.lat, lon: c.lon }))
);
/** Minimal API caller; bubbles GraphQL errors via /api/search handler */
async function callSearch(body: any) {
const resp = await $fetch("/api/search", { method: "POST", body });
if ((resp as any)?.errors) throw new Error("Search returned errors");
return (resp as any)?.data?.find as { total: number; documents: Doc[] };
}
/** Circle geo search (with cursor pagination) */
async function runCircle(append = false) {
const token = ++searchToken;
if (!append) {
docs.value = [];
total.value = 0;
cursor.value = null;
}
loading.value = true;
hasSearched.value = true;
try {
const find = await callSearch({
query: FIND_NEAR_CIRCLE,
variables: {
query: query.value || "*",
centerLat: center.value.lat,
centerLon: center.value.lon,
maxDistance: maxDistance.value, // Distance! scalar, e.g. "10mi"
limit: 20,
searchAfter: append ? cursor.value : null,
filter: FILTER,
includeFields: [...DEFAULT_INCLUDE_FIELDS],
// semantic optional; keep off by default unless configured server-side
semanticBias: 0,
semanticFields: [],
},
});
if (token !== searchToken) return; // drop stale page
// Trust server geo filter; no client-side distance filter needed
const page = (find?.documents ?? []).filter((d) => docCoordinates(d));
docs.value = append ? [...docs.value, ...page] : page;
total.value = find?.total ?? docs.value.length;
cursor.value = page.length ? page[page.length - 1]?.sort ?? null : null;
} catch (err) {
alert(`Search failed: ${(err as Error).message || err}`);
} finally {
if (token === searchToken) loading.value = false;
}
}
/** BBox search: keep signature for MapView contract (optional to implement later) */
async function runBBox(
_bbox: { swLat: number; swLon: number; neLat: number; neLon: number },
_userInitiated: boolean
) {
// You can wire FIND_IN_BBOX here later if you want "map bounds" search.
return;
}
/** Geolocate user and search from there */
function useMyLocation() {
if (!navigator.geolocation)
return alert("Geolocation is not supported by your browser");
navigator.geolocation.getCurrentPosition(
(pos) => {
const loc = { lat: pos.coords.latitude, lon: pos.coords.longitude };
center.value = loc;
userLocation.value = loc;
docs.value = [];
total.value = 0;
cursor.value = null;
runCircle(false);
},
(err) => {
loading.value = false;
if (err.code === 1)
alert(
"Location access was denied. Allow location access and try again."
);
else if (err.code === 2)
alert("Unable to determine your location. Please try again.");
else if (err.code === 3)
alert("Location request timed out. Please try again.");
else alert(`Error getting location: ${err.message}`);
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
);
}
/** Map click: set center & search */
function handleMapClick(loc: LatLon) {
center.value = loc;
userLocation.value = loc;
docs.value = [];
total.value = 0;
cursor.value = null;
runCircle(false);
}
/** Address → center via Google Geocoding */
async function searchAddress() {
if (!addressQuery.value.trim()) return;
geocoding.value = true;
try {
const config = useRuntimeConfig();
const res = await fetch(
`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(
addressQuery.value
)}&key=${config.public.googleMapsApiKey}`
);
const data = await res.json();
const first = data?.results?.[0];
if (!first) return alert("Address not found. Try a different address.");
const { lat, lng } = first.geometry.location;
center.value = { lat, lon: lng };
userLocation.value = { lat, lon: lng };
runCircle(false);
} catch {
alert("Failed to geocode address. Please try again.");
} finally {
geocoding.value = false;
}
}
onMounted(() => {
docs.value = [];
total.value = 0;
cursor.value = null;
});
</script>
<template>
<main class="mx-auto max-w-6xl p-6 space-y-6">
<h1 class="text-2xl font-semibold">
Geo Filter Smart Search AI Demo with Nuxt.js
</h1>
<div class="grid grid-cols-1 lg:grid-cols-[360px_1fr] gap-6">
<!-- Controls -->
<aside class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium">Search query</label>
<input
v-model="query"
class="w-full rounded-xl border px-3 py-2"
placeholder="bbq joints, events…"
/>
<div class="flex gap-2">
<button
class="rounded-lg border px-3 py-2"
@click="runCircle(false)"
>
Search
</button>
<button class="rounded-lg border px-3 py-2" @click="useMyLocation">
Use my location
</button>
</div>
</div>
<div class="space-y-2">
<label class="text-sm font-medium">Search by address</label>
<input
v-model="addressQuery"
class="w-full rounded-xl border px-3 py-2"
placeholder="123 Main St, Austin, TX"
@keyup.enter="searchAddress"
/>
<button
class="w-full rounded-lg border px-3 py-2"
@click="searchAddress"
:disabled="geocoding || !addressQuery.trim()"
>
{{ geocoding ? "Searching..." : "Search Address" }}
</button>
<p class="text-xs text-gray-500">Or click anywhere on the map</p>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium"
>Radius: {{ miles }} mi</label
>
<input
type="range"
min="1"
max="50"
step="1"
v-model="miles"
class="w-full"
@change="runCircle(false)"
/>
</div>
</aside>
<!-- Map + Results -->
<section class="space-y-4">
<MapView
:center="{ lon: center.lon, lat: center.lat }"
:markers="markers"
:userLocation="userLocation"
@boundsChanged="runBBox"
@mapClick="handleMapClick"
/>
<div class="flex items-center gap-3 text-sm text-gray-600">
<span class="rounded-lg border px-3 py-1">
{{
loading ? "Loading…" : `${total} result${total === 1 ? "" : "s"}`
}}
</span>
<button
class="rounded-lg border px-3 py-2"
@click="runCircle(true)"
:disabled="loading || !cursor"
>
Load more
</button>
</div>
<ul class="rounded-xl border divide-y">
<li v-for="d in docs" :key="d.id" class="p-3">
<a
:href="d?.data?.post_url || d?.data?.permalink || '#'"
target="_blank"
class="font-medium hover:underline"
>
{{ d?.data?.post_title || "Untitled" }}
</a>
<div class="text-sm">
{{ d?.data?.address || d?.data?.locationDetails?.address || "" }}
</div>
<div v-if="docCoordinates(d)" class="text-xs text-gray-500">
({{ docCoordinates(d)?.lat }}, {{ docCoordinates(d)?.lon }})
</div>
</li>
</ul>
</section>
</div>
</main>
</template>
Code language: HTML, XML (xml)
This page implements a geo-aware search UI that talks to your /api/search GraphQL proxy and renders results on a map. It tracks user inputs (text query, address, miles radius) plus map state (center, user location, pagination cursor, loading flags) with Vue refs.
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { FIND_NEAR_CIRCLE, DEFAULT_INCLUDE_FIELDS } from "~/graphql/queries";
type LatLon = { lat: number; lon: number };
type Doc = { id: string; score?: number; sort?: string[]; data: any };
const query = ref("");
const addressQuery = ref("");
const miles = ref(10);
const center = ref<LatLon>({ lat: 30.2672, lon: -97.7431 }); // Austin
const userLocation = ref<LatLon | null>(null);
const docs = ref<Doc[]>([]);
const total = ref(0);
const cursor = ref<string[] | null>(null);
const loading = ref(false);
const geocoding = ref(false);
const hasSearched = ref(false);
let searchToken = 0;
Code language: HTML, XML (xml)
A computed maxDistance converts the slider to the Distance! scalar (e.g., “10mi”), and a constant filter scopes results to the location post type. Results from Smart Search are normalized so each document reliably yields a { lat, lon } pair for map markers, regardless of whether coordinates arrives as an object or array.
/** Smart Search variables */
const maxDistance = computed(() => `${miles.value}mi`);
const FILTER = "post_type:location";
/** Normalize coordinates field that may be object or array */
function normalizeCoordinates(raw: unknown): LatLon | null {
if (!raw) return null;
const v = Array.isArray(raw) ? raw[0] : raw;
if (
v &&
typeof v === "object" &&
typeof (v as any).lat === "number" &&
typeof (v as any).lon === "number"
) {
const { lat, lon } = v as any;
return { lat, lon };
}
return null;
}
/** Resolve doc -> LatLon for markers */
function docCoordinates(d: Doc): LatLon | null {
// Prefer top-level "coordinates" that Smart Search uses for geo filters
return (
normalizeCoordinates(d?.data?.coordinates) ??
// fallback if you still return nested shape (not required)
normalizeCoordinates(d?.data?.locationDetails?.coordinates) ??
null
);
}
/** Markers for the map */
const markers = computed(() =>
docs.value
.map(docCoordinates)
.filter((c): c is LatLon => !!c)
.map((c) => ({ lat: c.lat, lon: c.lon }))
);
Code language: JavaScript (javascript)
The runCircle action performs the main “near me” search with cursor pagination, deduplicates stale responses via a rolling token, and updates totals, docs, and next-page cursors. Users can set the center by clicking the map, using browser geolocation, or geocoding a typed address with Google’s API; each path recenters the map and triggers a fresh search.
/** Minimal API caller; bubbles GraphQL errors via /api/search handler */
async function callSearch(body: any) {
const resp = await $fetch("/api/search", { method: "POST", body });
if ((resp as any)?.errors) throw new Error("Search returned errors");
return (resp as any)?.data?.find as { total: number; documents: Doc[] };
}
/** Circle geo search (with cursor pagination) */
async function runCircle(append = false) {
const token = ++searchToken;
if (!append) {
docs.value = [];
total.value = 0;
cursor.value = null;
}
loading.value = true;
hasSearched.value = true;
try {
const find = await callSearch({
query: FIND_NEAR_CIRCLE,
variables: {
query: query.value || "*",
centerLat: center.value.lat,
centerLon: center.value.lon,
maxDistance: maxDistance.value, // Distance! scalar, e.g. "10mi"
limit: 20,
searchAfter: append ? cursor.value : null,
filter: FILTER,
includeFields: [...DEFAULT_INCLUDE_FIELDS],
// semantic optional; keep off by default unless configured server-side
semanticBias: 0,
semanticFields: [],
},
});
if (token !== searchToken) return; // drop stale page
// Trust server geo filter; no client-side distance filter needed
const page = (find?.documents ?? []).filter((d) => docCoordinates(d));
docs.value = append ? [...docs.value, ...page] : page;
total.value = find?.total ?? docs.value.length;
cursor.value = page.length ? page[page.length - 1]?.sort ?? null : null;
} catch (err) {
alert(`Search failed: ${(err as Error).message || err}`);
} finally {
if (token === searchToken) loading.value = false;
}
}
/** BBox search: keep signature for MapView contract (optional to implement later) */
async function runBBox(
_bbox: { swLat: number; swLon: number; neLat: number; neLon: number },
_userInitiated: boolean
) {
// You can wire FIND_IN_BBOX here later if you want "map bounds" search.
return;
}
/** Geolocate user and search from there */
function useMyLocation() {
if (!navigator.geolocation)
return alert("Geolocation is not supported by your browser");
navigator.geolocation.getCurrentPosition(
(pos) => {
const loc = { lat: pos.coords.latitude, lon: pos.coords.longitude };
center.value = loc;
userLocation.value = loc;
docs.value = [];
total.value = 0;
cursor.value = null;
runCircle(false);
},
(err) => {
loading.value = false;
if (err.code === 1)
alert(
"Location access was denied. Allow location access and try again."
);
else if (err.code === 2)
alert("Unable to determine your location. Please try again.");
else if (err.code === 3)
alert("Location request timed out. Please try again.");
else alert(`Error getting location: ${err.message}`);
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
);
}
/** Map click: set center & search */
function handleMapClick(loc: LatLon) {
center.value = loc;
userLocation.value = loc;
docs.value = [];
total.value = 0;
cursor.value = null;
runCircle(false);
}
/** Address → center via Google Geocoding */
async function searchAddress() {
if (!addressQuery.value.trim()) return;
geocoding.value = true;
try {
const config = useRuntimeConfig();
const res = await fetch(
`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(
addressQuery.value
)}&key=${config.public.googleMapsApiKey}`
);
const data = await res.json();
const first = data?.results?.[0];
if (!first) return alert("Address not found. Try a different address.");
const { lat, lng } = first.geometry.location;
center.value = { lat, lon: lng };
userLocation.value = { lat, lon: lng };
runCircle(false);
} catch {
alert("Failed to geocode address. Please try again.");
} finally {
geocoding.value = false;
}
}
onMounted(() => {
docs.value = [];
total.value = 0;
cursor.value = null;
});
Code language: JavaScript (javascript)
The template wires these behaviors into a simple Tailwind layout with inputs, a distance slider, a MapView component for visualization, and a paginated results list that links to each item’s URL.
<template>
<main class="mx-auto max-w-6xl p-6 space-y-6">
<h1 class="text-2xl font-semibold">
Geo Filter Smart Search AI Demo with Nuxt.js
</h1>
<div class="grid grid-cols-1 lg:grid-cols-[360px_1fr] gap-6">
<!-- Controls -->
<aside class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium">Search query</label>
<input
v-model="query"
class="w-full rounded-xl border px-3 py-2"
placeholder="bbq joints, events…"
/>
<div class="flex gap-2">
<button
class="rounded-lg border px-3 py-2"
@click="runCircle(false)"
>
Search
</button>
<button class="rounded-lg border px-3 py-2" @click="useMyLocation">
Use my location
</button>
</div>
</div>
<div class="space-y-2">
<label class="text-sm font-medium">Search by address</label>
<input
v-model="addressQuery"
class="w-full rounded-xl border px-3 py-2"
placeholder="123 Main St, Austin, TX"
@keyup.enter="searchAddress"
/>
<button
class="w-full rounded-lg border px-3 py-2"
@click="searchAddress"
:disabled="geocoding || !addressQuery.trim()"
>
{{ geocoding ? "Searching..." : "Search Address" }}
</button>
<p class="text-xs text-gray-500">Or click anywhere on the map</p>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium"
>Radius: {{ miles }} mi</label
>
<input
type="range"
min="1"
max="50"
step="1"
v-model="miles"
class="w-full"
@change="runCircle(false)"
/>
</div>
</aside>
<!-- Map + Results -->
<section class="space-y-4">
<MapView
:center="{ lon: center.lon, lat: center.lat }"
:markers="markers"
:userLocation="userLocation"
@boundsChanged="runBBox"
@mapClick="handleMapClick"
/>
<div class="flex items-center gap-3 text-sm text-gray-600">
<span class="rounded-lg border px-3 py-1">
{{
loading ? "Loading…" : `${total} result${total === 1 ? "" : "s"}`
}}
</span>
<button
class="rounded-lg border px-3 py-2"
@click="runCircle(true)"
:disabled="loading || !cursor"
>
Load more
</button>
</div>
<ul class="rounded-xl border divide-y">
<li v-for="d in docs" :key="d.id" class="p-3">
<a
:href="d?.data?.post_url || d?.data?.permalink || '#'"
target="_blank"
class="font-medium hover:underline"
>
{{ d?.data?.post_title || "Untitled" }}
</a>
<div class="text-sm">
{{ d?.data?.address || d?.data?.locationDetails?.address || "" }}
</div>
<div v-if="docCoordinates(d)" class="text-xs text-gray-500">
({{ docCoordinates(d)?.lat }}, {{ docCoordinates(d)?.lon }})
</div>
</li>
</ul>
</section>
</div>
</main>
</template>
Code language: HTML, XML (xml)
Stoked!!! We are now ready to try the map!
Test The Map
Navigate to your terminal. Do not forget to run npm run install. Then run npm run dev. You should see this in all its glory:

Conclusion
This project demonstrates the combination of headless architecture, Nuxt.js, and Smart Search AI, proving that map functionality combined with AI is the way for users to find locations using natural language.
We’d love to hear what you build with this—drop into the Headless WordPress Discord and share your projects or feedback. Happy Coding!
