Semantic search in Nuxt with XEYE
To add semantic search to a Nuxt app you need three files: the API key in the private configuration, a server route that calls XEYE and a page with the search box. The key never leaves the server. It works the same for Nuxt 4 and Nuxt 3.
By Joan Martorell
What do I need before I start?
- An XEYE account with a public, trained list. If you do not have one yet, follow getting started.
- An API key, created on the API keys page of the console. It is shown in full only once.
- A Nuxt app with a server: local development, Node, or a deployment on Vercel, Netlify or Cloudflare. A site generated as static only will not work, because it would have nowhere to keep the key.
In the examples the list is called products. Replace that name with the name of yours.
Step 1: where do I store the API key?
In the private part of runtimeConfig, which only exists on the server. Declare it empty in the configuration and set the value with the NUXT_XEYE_API_KEY environment variable:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// Private: only available on the server. Set it with NUXT_XEYE_API_KEY.
xeyeApiKey: '',
},
})
In development, put it in the .env file. In production that file is not read: define the environment variable on your deployment platform.
Do not put it inside runtimeConfig.public or use it in a component. Everything public travels to the browser, and anyone who has the key can search your lists and spend your credit.
Step 2: how do I call XEYE from the server?
With a server route. It receives the search from the browser, validates it, calls XEYE with the key and returns only the results. The file goes in server/api/, which is at the project root in both Nuxt 4 and Nuxt 3:
// server/api/search.post.ts
export default defineEventHandler(async (event) => {
const { xeyeApiKey } = useRuntimeConfig(event)
const body = await readBody<{ q?: unknown; limit?: unknown }>(event)
const q = typeof body?.q === 'string' ? body.q.trim() : ''
if (q.length < 2 || q.length > 200) {
throw createError({ status: 400, statusText: 'Invalid query' })
}
const limit = Math.min(Math.max(Number(body?.limit) || 5, 1), 20)
try {
const response = await $fetch<{ results: unknown[] }>(
'https://search.xeye.es/api/v1/search',
{
method: 'POST',
headers: { 'X-API-Key': xeyeApiKey },
// The list name is fixed here: the browser cannot query other lists.
body: { list_name: 'products', search_term: q, limit },
},
)
return { results: response.results }
} catch {
throw createError({ status: 502, statusText: 'Search unavailable' })
}
})
Two details are worth keeping. The list name is fixed on the server, so the browser cannot query other lists. And the length and the limit are validated before the call, because every search that reaches XEYE spends credit.
Step 3: how do I render the search box?
With a page that calls your route, not XEYE. This example waits 300 milliseconds after the last keystroke before searching and discards responses that arrive out of order:
<!-- app/pages/search.vue (Nuxt 4) · pages/search.vue (Nuxt 3) -->
<script setup lang="ts">
interface Result {
item: string
score: number
params?: { url?: string; sku?: string }
}
const q = ref('')
const results = ref<Result[]>([])
let timer: ReturnType<typeof setTimeout> | undefined
let latest = 0
watch(q, (value) => {
clearTimeout(timer)
const term = value.trim()
if (term.length < 2) {
results.value = []
return
}
// Wait until the user stops typing: one search, not one per keystroke.
timer = setTimeout(async () => {
const id = ++latest
const response = await $fetch<{ results: Result[] }>('/api/search', {
method: 'POST',
body: { q: term, limit: 5 },
})
if (id === latest) results.value = response.results // drop stale answers
}, 300)
})
</script>
<template>
<input v-model="q" type="search" placeholder="Search…" />
<ul>
<li v-for="result in results" :key="result.params?.sku ?? result.item">
<a :href="result.params?.url">{{ result.item }}</a>
</li>
</ul>
</template>
Each result carries in params whatever you stored with the element. If you included the URL, the price or the image, you already have what you need to render a card without querying your database.
How do I test it?
- Start the app with
NUXT_XEYE_API_KEYset. - Open
/searchand type a query with a synonym or a typo. - If it returns nothing, try the same search in the console playground: it will tell you whether the problem is in the list or in the integration.
What errors can I run into?
| XEYE response | What it means | What to do |
|---|---|---|
401 | The key is missing or not valid. | Check the environment variable on the server. |
403 LIST_NOT_PUBLIC | The list is private. | Mark it as public in the console. |
404 LIST_NOT_FOUND | There is no list with that name in your account. | Check list_name. |
402 CREDIT_EXHAUSTED | There is no credit left. | Request more from the Credit page. |
429 RATE_LIMITED | You have exceeded the per-minute limit. | Respect the Retry-After header. |
In the example, any failure becomes a 502 for the browser. The full list of errors is in the API integration guide.
Frequently asked questions
Does it work with Nuxt 3?
- Yes. The server route is identical. The only thing that changes is where the page lives:
app/pages/in Nuxt 4 andpages/in Nuxt 3. Can I use `useFetch` instead of `$fetch`?
- Yes. For a search that fires as you type,
$fetchinside awatchis the most direct option.useFetchfits better when the search comes from the URL and you want it rendered on the server. How do I avoid spending one search per keystroke?
- With the 300 millisecond wait from the example. You can also require a minimum number of characters and keep the responses to repeated queries in memory.
Try it with your own data
Create an account, upload a list and run your first search in about five minutes. You start with €5 of credit, no card required.
Keep reading
- Semantic search API: what it is and how to choose oneA semantic search API returns the elements in your catalogue that mean the same as the query. What it does, what you need and how to choose one.
- Semantic search in Shopify with XEYEAdd a search box that understands synonyms and typos to your Shopify store: export the products, train them in XEYE and connect the theme through a small proxy.
- XEYE pricingXEYE costs €0.001 per API search and €0.30 per training, plus €0.0053 per AI description. No fees, and €5 of starting credit.