Typing a full query and waiting for results feels outdated when every other app suggests answers as you go. Here's how to bring the same instant feedback to a Webflow site that already runs Elasticsearch on Webflow Cloud.
Full-text Elasticsearch search answers the query after a user submits it. Autocomplete answers it while they type, surfacing matching suggestions with every keystroke.
If you've already built full-text search with Elasticsearch on Webflow Cloud, the autocomplete upgrade is an additive change. One new field type in your index mapping, a dedicated Route Handler endpoint, and a debounced, abort-controlled input handler on the Webflow frontend. You don't rebuild what you already have. You extend it.
In this guide, we walk through migrating your index to the search_as_you_type mapping, re-indexing your existing content, building a dedicated autocomplete Route Handler, and wiring up a debounced, abort-controlled input on the Webflow frontend.
What do you need to add an Elasticsearch autocomplete search to Webflow Cloud?
You need an Elastic Cloud cluster with an existing Webflow CMS index, a Webflow Cloud Next.js app with Route Handler support, and a Webflow Data API token with the cms:read scope.
Here's the full list.
An Elasticsearch cluster on Elastic Cloud
Your cluster needs to exist on Elastic Cloud and have at least one index already created. Adding search_as_you_type to a field that was previously mapped as text requires creating a new index with the updated mapping and re-indexing your content, which Steps 1 and 2 cover.
If you're creating a fresh index with no prior mapping, you can define title as search_as_you_type from the start and skip the migration step entirely.
You need two API keys from the Elastic Cloud console under Management and then API Keys: a read-only key for the Route Handler that handles live autocomplete queries, and a write-capable key for the local indexing script that populates the index from your Webflow CMS. Keep them separate.
A leaked read-only key exposes your search results to outside queries. A leaked write key can corrupt or delete the entire index. The read key is stored in Webflow Cloud's environment variables. The write key stays in .env.local and never gets deployed.
A Webflow Cloud Next.js app with at least one Route Handler
You need a Next.js project deployed on Webflow Cloud, or at a minimum, initialized locally with the Webflow CLI. If this is your first Webflow Cloud project, the Webflow Cloud getting started guide walks through creating a project from a starter template in under ten minutes.
If you're layering additional services in the same project alongside the search endpoint, building a full-stack app on Webflow Cloud with Supabase, Auth0, and Stripe covers how those integrations coexist in a single Next.js app.
5 steps to add Elasticsearch autocomplete search to Webflow Cloud
The implementation adds a search_as_you_type field to a new index, runs a re-index to populate the shingle subfields, creates a dedicated autocomplete Route Handler, and wires up the frontend input with debouncing and abort control.
These steps assume you have an existing Elasticsearch setup from the full-text search guide. If you're starting completely fresh, complete that guide first and return here once the full-text search endpoint is working.
Let's go through each step.
1. Create a new Elasticsearch index with the search_as_you_type field mapping
Elasticsearch does not allow modifying a field's type in an existing index. If your title field was previously mapped as text, you cannot change it to search_as_you_type in place. The solution is creating a new index with the updated mapping and re-indexing your content into it. The indexing script in Step 2 handles the data migration in a single command.
Run the following in Kibana Dev Tools, accessible from your Elastic Cloud deployment console, or send it as a curl request from your terminal:
PUT /webflow-content-v2
{
"mappings": {
"properties": {
"title": {
"type": "search_as_you_type",
"max_shingle_size": 3
},
"slug": { "type": "keyword" },
"body": { "type": "text" },
"excerpt": { "type": "text" },
"category": { "type": "keyword" },
"tags": { "type": "keyword" },
"published_at": { "type": "date" },
"webflow_item_id": { "type": "keyword" }
}
}
}
The title field is now mapped as search_as_you_type with max_shingle_size: 3. Elasticsearch automatically generates title._2gram, title._3gram, and title._index_prefix as internal subfields. You do not define those manually.
The default of 3 is the right value for most content titles. Setting max_shingle_size: 4 enables more precise four-word phrase matching at the cost of a larger index. Stick with 3 unless your titles frequently run 5 or more words, and 4-word phrases don't match when they should.
The body and excerpt fields stay as text because full-text search operates on them. Autocomplete targets only title, which is the correct default for content navigation. Suggesting partial body sentences produces incomplete, out-of-context suggestions. Suggesting partial titles gives users immediately recognizable destinations.
Verifying the mapping
After creating the index, verify the mapping before proceeding:
GET /webflow-content-v2/_mapping
The response should show title with "type": "search_as_you_type" and the three internal subfields listed beneath it. If title still shows "type": "text", the PUT request didn't apply or you're querying the wrong index name. Fix the mapping before running Step 2.
2. Re-index your Webflow CMS content into the new index
With the new index in place, re-run the indexing script against webflow-content-v2. If you followed the full-text search guide, your script lives at scripts/index-webflow-content.ts.
The version below updates the index target and adds the excerpt field, which carries through to the autocomplete suggestions in Step 4.
// scripts/index-webflow-content.ts
// Run with: npx tsx scripts/index-webflow-content.ts
const WEBFLOW_TOKEN = process.env.WEBFLOW_API_TOKEN!
const COLLECTION_ID = process.env.WEBFLOW_COLLECTION_ID!
const ELASTIC_URL = process.env.ELASTIC_URL!
const ELASTIC_WRITE_KEY = process.env.ELASTIC_WRITE_API_KEY!
const INDEX = 'webflow-content-v2'
interface WebflowItem {
id: string
lastPublished: string
fieldData: {
name: string
slug: string
'post-body'?: string
excerpt?: string
category?: string
tags?: string[]
}
}
async function fetchAllItems(): Promise<WebflowItem[]> {
const items: WebflowItem[] = []
let offset = 0
while (true) {
const res = await fetch(
`https://api.webflow.com/v2/collections/${COLLECTION_ID}/items?offset=${offset}&limit=100`,
{ headers: { Authorization: `Bearer ${WEBFLOW_TOKEN}` } }
)
const data = await res.json() as {
items: WebflowItem[]
pagination: { total: number }
}
items.push(...data.items)
if (items.length >= data.pagination.total) break
offset += 100
}
return items
}
async function bulkIndex(items: WebflowItem[]): Promise<void> {
const ndjson =
items
.flatMap((item) => [
JSON.stringify({ index: { _index: INDEX, _id: item.id } }),
JSON.stringify({
title: item.fieldData.name,
slug: item.fieldData.slug,
body: item.fieldData['post-body'] ?? '',
excerpt: item.fieldData.excerpt ?? '',
category: item.fieldData.category ?? '',
tags: item.fieldData.tags ?? [],
published_at: item.lastPublished,
webflow_item_id: item.id,
}),
])
.join('\n') + '\n'
const res = await fetch(`${ELASTIC_URL}/_bulk`, {
method: 'POST',
headers: {
Authorization: `ApiKey ${ELASTIC_WRITE_KEY}`,
'Content-Type': 'application/x-ndjson',
},
body: ndjson,
})
const result = await res.json() as {
items: Array<{ index?: { error?: unknown } }>
}
const indexed = result.items.length
const errors = result.items.filter((i) => i.index?.error).length
console.log(`Indexed ${indexed} items into ${INDEX}. Errors: ${errors}`)
if (errors > 0) {
console.error('First error:',
result.items.find((i) => i.index?.error)?.index?.error)
}
}
const items = await fetchAllItems()
console.log(`Fetched ${items.length} items from Webflow CMS`)
await bulkIndex(items)
Run the script from your project root after confirming that all required variables are set in .env.local:
npx tsx scripts/index-webflow-content.ts
The bulk API requires Content-Type: application/x-ndjson, not standard application/json. Each request body alternates between an action line and a document line, with a required trailing newline after the last document.
The + '\n' at the end of the .join('\n') call handles this. Missing that trailing newline causes a silent parse error where the last document in every batch gets dropped without any obvious error in the response.
After the script completes, verify the index has content:
GET /webflow-content-v2/_count
The count should match the size of your Webflow CMS collection. A count of zero with no script errors usually means the _id field was undefined on every document, which skips the index action without failing. Log item.id in fetchAllItems() to confirm your Webflow items have valid IDs before re-running.
3. Build the autocomplete Route Handler in Webflow Cloud
Create app/api/autocomplete/route.ts. This lives alongside but separate from the full-text search handler at app/api/search/route.ts.
I keep them separate on every project because they handle fundamentally different interactions: autocomplete fires on every debounced keystroke and returns a small, fast list of titles; full-text search fires on submission and returns paginated results with highlights and facets.
Merging them creates a handler that's harder to tune, harder to rate-limit independently, and harder to debug when something goes wrong with one behavior but not the other.
// app/api/autocomplete/route.ts
import { NextResponse, type NextRequest } from 'next/server'
const ELASTIC_URL = process.env.ELASTIC_URL!
const ELASTIC_API_KEY = process.env.ELASTIC_API_KEY!
const INDEX = process.env.ELASTIC_INDEX ?? 'webflow-content-v2'
interface ElasticHit {
_source: {
title: string
slug: string
excerpt?: string
}
}
interface ElasticResponse {
hits: {
total: { value: number }
hits: ElasticHit[]
}
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const q = searchParams.get('q')?.trim() ?? ''
if (q.length < 2) {
return NextResponse.json({ suggestions: [] })
}
const esQuery = {
size: 8,
_source: ['title', 'slug', 'excerpt'],
query: {
multi_match: {
query: q,
type: 'bool_prefix',
fields: [
'title',
'title._2gram',
'title._3gram',
],
},
},
}
const esRes = await fetch(`${ELASTIC_URL}/${INDEX}/_search`, {
method: 'POST',
headers: {
Authorization: `ApiKey ${ELASTIC_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(esQuery),
})
if (!esRes.ok) {
const errorText = await esRes.text()
console.error('Autocomplete Elasticsearch error:', errorText)
return NextResponse.json(
{ error: 'Autocomplete unavailable' },
{ status: 502 }
)
}
const data = (await esRes.json()) as ElasticResponse
const suggestions = data.hits.hits.map((hit) => ({
title: hit._source.title,
slug: hit._source.slug,
excerpt: hit._source.excerpt ?? null,
}))
return NextResponse.json({ suggestions })
}
The bool_prefix query type is the recommended pattern for search_as_you_type fields. It analyzes every term in the query string except the last one using the field's standard analyzer, and treats the final term as a prefix.
A partial query of "webflow ele" matches documents containing "webflow" as a complete token and "ele" as a prefix, surfacing titles like "Webflow Elasticsearch Guide" immediately. The shingle subfields (_2gram, _3gram) score documents higher when consecutive typed terms appear together in the original field value, improving multi-word query relevance without any additional configuration.
The size: 8 cap keeps the dropdown from overwhelming the user. Through production use across Webflow sites, I've found that seven or eight suggestions is the practical ceiling. Below 5, the dropdown feels sparse for common queries. Above ten, users stop reading the list and just submit the query anyway.
The two-character minimum prevents the handler from returning a near-full-index scan on single-character inputs. A query of "w" on a 500-post blog returns eight essentially random documents. A query of "we" narrows enough to be directional. For content-dense sites with several thousand CMS items, three characters as the minimum is an even better threshold.
4. Wire up the autocomplete dropdown on the Webflow frontend
Add this script to a Custom Code block in Webflow's page settings under Footer Code, just before the closing </body> tag.
In the Webflow canvas, give your search input element a custom attribute of data-autocomplete-input and the suggestions container element a custom attribute of data-autocomplete-results. Style both using your project's existing CSS classes.
(function () {
const input = document.querySelector('[data-autocomplete-input]')
const results = document.querySelector('[data-autocomplete-results]')
if (!input || !results) return
let debounceTimer
let controller
input.addEventListener('input', function () {
const q = this.value.trim()
clearTimeout(debounceTimer)
if (q.length < 2) {
results.hidden = true
results.innerHTML = ''
return
}
debounceTimer = setTimeout(function () {
fetchSuggestions(q)
}, 200)
})
async function fetchSuggestions(q) {
if (controller) controller.abort()
controller = new AbortController()
try {
const res = await fetch(
'/app/api/autocomplete?q=' + encodeURIComponent(q),
{ signal: controller.signal }
)
if (!res.ok) return
const data = await res.json()
renderSuggestions(data.suggestions)
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Autocomplete fetch error:', err)
}
}
}
function renderSuggestions(suggestions) {
if (!suggestions || !suggestions.length) {
results.hidden = true
results.innerHTML = ''
return
}
results.innerHTML = suggestions
.map(function (s) {
return (
'<a href="/' + s.slug + '" class="autocomplete-suggestion">' +
'<span class="autocomplete-title">' + escapeHtml(s.title) + '</span>' +
(s.excerpt
? '<span class="autocomplete-excerpt">' + escapeHtml(s.excerpt) + '</span>'
: '') +
'</a>'
)
})
.join('')
results.hidden = false
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
}
document.addEventListener('click', function (e) {
if (!input.contains(e.target) && !results.contains(e.target)) {
results.hidden = true
}
})
})()
The AbortController is the most important addition relative to a basic debounce implementation. When a user types "web" and then immediately continues to "webfl", two fetch calls fire at 200ms intervals.
Without an abort controller, both responses arrive, and if the network delivers the "web" response after the "webfl" one, the stale shorter-query results overwrite the correct ones. The user sees suggestions jump backward to an earlier, less specific query.
The abort controller cancels the previous request before starting a new one, so renderSuggestions only ever receives the response for the most recent query. I've hit this exact stale-result bug in production with fast typists, and it quickly erodes search credibility.
The 200ms debounce is intentionally tighter than the 300ms I use on full-text search. Autocomplete feels snappy at 200ms; anything above 250ms starts lagging noticeably behind faster typists.
Webflow Cloud's edge runtime helps keep this window tight because Route Handlers execute from Cloudflare edge locations close to the user rather than from a single origin server.
The escapeHtml function is not optional. Suggestion titles and excerpts come from CMS content that real authors write, and those authors occasionally include <, >, &, or " in a title or excerpt. Without escaping, those characters break the HTML string, creating an XSS vulnerability. This is a real production concern, not a theoretical edge case.
5. Update your Elastic Cloud environment variable and deploy
The autocomplete Route Handler reads the same ELASTIC_URL, ELASTIC_API_KEY, and ELASTIC_INDEX environment variables that the full-text search handler already uses. The only change is updating ELASTIC_INDEX to point at webflow-content-v2, or whatever you named the new index in Step 1.
In Webflow Cloud, open your project, select the environment, and navigate to Environment Variables. Update ELASTIC_INDEX to the new index name. Confirm that ELASTIC_API_KEY holds the read-only key. The write key (ELASTIC_WRITE_API_KEY) should only exist in your local .env.local file and should never reach the deployed environment.
After updating the variable, deploy:
webflow cloud deploy
Once the deployment completes, test the autocomplete endpoint directly before touching the frontend:
curl "https://your-site.webflow.io/app/api/autocomplete?q=web"
The response should be a JSON object with a suggestions array containing up to eight items, each with title, slug, and optionally excerpt. An empty array at this stage almost always means ELASTIC_INDEX still points at the old index, or the re-indexing script in Step 2 ran against a differently named index. Check both before investigating anything else.
What causes Elasticsearch autocomplete to fail on Webflow Cloud?
Most autocomplete failures on Webflow Cloud trace back to four categories:
- A mismatched index name between the environment variable and the actual index
- The
search_as_you_typeshingle subfields are being queried on an index where thetitleis still mapped as plaintext - Stale suggestions reaching the frontend because the
AbortControllerisn't structured correctly - CORS errors when the Webflow site and the Webflow Cloud app live on different origins
Let's work through each one.
The autocomplete endpoint returns an empty suggestions array
An empty suggestions array when the index has content points to one of three causes: the ELASTIC_INDEX environment variable still references the old index that doesn't have the search_as_you_type mapping, the query string is shorter than two characters and the handler returns early, or the re-indexing script completed but wrote documents to a differently named index.
The fastest diagnostic is querying Elasticsearch directly in Kibana Dev Tools:
POST /webflow-content-v2/_search
{
"size": 3,
"query": {
"multi_match": {
"query": "web",
"type": "bool_prefix",
"fields": [
"title",
"title._2gram",
"title._3gram",
"title._index_prefix"
]
}
}
}
If this returns results in Kibana but the Route Handler returns empty, the environment variable is pointing at a different index. If this also returns empty in Kibana, run GET /webflow-content-v2/_mapping and confirm that title.type shows search_as_you_type.
If it shows text, delete the index, recreate it with the explicit mapping from Step 1, and re-run the indexing script.
The Route Handler returns a 400 error from Elasticsearch
A 400 response on the autocomplete query almost always means the title._2gram, title._3gram, and title._index_prefix subfields don't exist on the index being queried.
This happens when title is mapped as text rather than search_as_you_type, which occurs whenever Elasticsearch auto-detected the field type on the first document write. Elasticsearch's dynamic mapping always chooses text for string content, never search_as_you_type.
Explicit mappings defined before indexing are the only way to get the right field type.
Confirm with GET /webflow-content-v2/_mapping. If title.type is not search_as_you_type, delete the index, recreate it with the PUT mapping from Step 1, and re-run the indexing script against the newly created index.
Stale suggestions from an earlier query appear before the current ones
If you see suggestions from a previous query briefly before the correct ones, replace them. The AbortController in Step 4 is not correctly canceling in-flight requests. The most common cause is declaring the controller variable inside the setTimeout callback or inside fetchSuggestions itself, rather than in the outer IIFE scope.
When a controller is declared locally, each new keystroke creates a new, unrelated variable rather than overwriting the shared one. The controller.abort() call never cancels the right request because the variable it references was just created, not the one holding the previous fetch.
The controller variable must live in the IIFE's module scope, outside both the setTimeout callback and fetchSuggestions, so every new call overwrites the same shared reference and can abort the previous request. The script in Step 4 places it correctly at the top of the IIFE before the event listener.
CORS errors when calling the Route Handler from a separate Webflow site
If your Webflow site is hosted on one domain and your Webflow Cloud app lives on another, the browser's same-origin policy blocks the autocomplete fetch. The fix is adding CORS headers to the Route Handler.
Add an OPTIONS handler for preflight requests and include Access-Control-Allow-Origin in the GET response:
const CORS_HEADERS = {
'Access-Control-Allow-Origin': 'https://your-site.webflow.io',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
} as const
export async function OPTIONS() {
return new Response(null, { status: 204, headers: CORS_HEADERS })
}
Pass { headers: CORS_HEADERS } as the second argument to NextResponse.json() in the GET handler's return statements. Replace the origin value with your actual published Webflow site URL.
Using Access-Control-Allow-Origin: * is simpler but makes the endpoint callable from any origin on the internet, exposing your Elastic Cloud query capacity to anyone who discovers the URL.
Extend autocomplete search in your Webflow Cloud app
The setup in this guide delivers a complete, production-ready autocomplete implementation: a search_as_you_type index mapping, a Webflow Cloud Route Handler using multi_match with bool_prefix, and a debounced, abort-controlled frontend input that renders title suggestions as the user types.
For error monitoring across your Route Handlers, adding Sentry error tracking to a Webflow Cloud app covers capturing and surfacing errors from edge Route Handlers. To see how the deployment workflow and environment variable patterns apply in a different integration context, building a serverless app with Neon on Webflow Cloud is a useful reference.
Explore Webflow + Elastic for additional integration approaches, authentication options, and the full breakdown of compatible Elasticsearch client approaches for Webflow Cloud projects.
Frequently asked questions
Does search_as_you_type work on Webflow Cloud's Workers runtime?
Yes. The search_as_you_type type is a mapping configuration on the Elasticsearch index, not a client library with runtime dependencies. You query it via the Elasticsearch HTTP REST API using fetch(), which the Cloudflare Workers runtime supports natively. The multi_match with bool_prefix query is standard Elasticsearch DSL that works identically whether called from Node.js, a browser, or a Workers runtime: no SDK, no compatibility shim, no runtime errors.
Do I need to re-index all my content to add autocomplete?
Yes, because changing a field's type from text to search_as_you_type requires a new index with the updated mapping. Elasticsearch doesn't allow field type changes on an existing index. For most Webflow CMS collections, the re-indexing script in Step 2 completes in under a minute. For collections with several thousand items, Elasticsearch's Reindex API can copy documents between indices server-side without re-fetching them from the Webflow Data API, which is faster for large datasets and avoids API rate limit concerns.
Can I autocomplete and full-text search from the same Route Handler endpoint?
Technically, yes, but two separate endpoints are the better design. The autocomplete query targets search_as_you_type subfields with bool_prefix, returns eight results with no pagination, and fires on every debounced keystroke. The full-text query uses fuzziness: AUTO, returns paginated results with highlights and facets, and fires on form submission. Merging them adds conditional branching for behaviors with different query shapes, different performance targets, and different rate-limiting concerns. Two handlers, each doing one thing cleanly, are worth the minor code duplication.
How do I keep autocomplete suggestions up to date as CMS content changes?
Register a Webflow webhook for collection_item_created and collection_item_changed, pointing to a Route Handler that re-indexes the changed item. In the handler, fetch the updated CMS item from the Webflow Data API, then call the Elasticsearch single-document index endpoint PUT /webflow-content-v2/_doc/{webflow_item_id} with the updated field values. This keeps the index current without running the full bulk script on every publish event. For collections that update frequently, this incremental approach is the only practical production solution.
What is the minimum query length for useful autocomplete results?
Two characters are the practical minimum for meaningful suggestions on most Webflow sites. A single character returns too many unrelated matches to be useful, and on Elastic Cloud's free tier, scanning the full index for a one-character prefix creates noticeable latency. Two characters match enough to be directional. For content-dense sites with more than 5,000 CMS items, three characters is a better threshold. The minimum is enforced in both the Route Handler and the frontend script, so neither fires a request that would return a confusing result set.
Can I add category filtering to the autocomplete dropdown?
Yes. Add a category query parameter to the Route Handler, then wrap the existing multi_match inside a bool query with a filter clause using a term query on the category keyword field. The filter restricts suggestions to documents that match that category, without affecting relevance scoring for title matches. This follows the same pattern as the full-text search Route Handler in the companion guide, where term filters sit inside a bool.filter alongside the main multi_match in bool.must.




