How to build full-text search for Webflow CMS using Elasticsearch and a Route Handler

Learn how to add full-text search to any Webflow site using Elasticsearch and a Webflow Cloud Route Handler.

How to build full-text search for Webflow CMS using Elasticsearch and a Route Handler

Colin Lateano
Developer Evangelist
View author profile
Colin Lateano
Developer Evangelist
View author profile
Table of contents

Webflow's CMS filtered lists are powerful for sorting and display, but the moment a user types a partial word or searches across multiple fields at once, they come up empty. Elasticsearch closes that gap, and Webflow Cloud gives you a place to run the proxy without a separate server.

Webflow's native CMS filtered lists are good at sorting and filtering, but they don't support full-text search across multiple fields or handle spelling variations. If you need a query to match against post titles, body content, tags, and author names at once, you need something else running underneath.

You can stand up an Elasticsearch cluster. The architecture is straightforward. Webflow CMS content gets indexed into Elasticsearch once (and re-indexed on publish).

A Webflow Cloud App Route Handler accepts search queries from the frontend, forwards them to Elasticsearch using the HTTP API, and returns shaped results. The Webflow site sends a fetch() call to that Route Handler and renders the response.

In this guide, we walk through setting up an Elasticsearch cluster on Elastic Cloud, building a Route Handler in your Webflow Cloud App to proxy and shape search queries, indexing your Webflow CMS content with a local script, and wiring up the search input on your Webflow site.

What do you need to build an Elasticsearch full-text search on Webflow Cloud?

You need an Elasticsearch cluster, a Webflow Cloud App, and a Webflow Data API token. No npm packages beyond what the Cloud App scaffold already provides.

Here are the prerequisites:

  • An Elastic Cloud account: The 14-day free trial gives you a hosted Elasticsearch cluster with a public HTTPS endpoint. No self-hosted setup required
  • Webflow Cloud App: Run webflow auth login && webflow cloud init if you haven't yet.
  • AWebflow Data API token: To fetch CMS items to index. Generate one at Site settings > Apps & integrations > API access. When selecting scopes, enable cms:read. That's all the indexing script needs. Use a separate token with write access only in your local indexing script, never in the deployed Route Handler.
  • The CMS Collection ID you want to make searchable. Find it in the Webflow Designer under CMS → Collection Settings

Once these are in place, the setup takes about 30 to 45 minutes. Here's how.

One thing worth knowing upfront: the official @elastic/elasticsearch Node.js client doesn't work on Cloudflare Workers. It relies on Node.js APIs that the V8 isolate runtime doesn't expose.

There's a tracking issue at elastic/elasticsearch-js #2032 with no fix shipped. I use the Elasticsearch HTTP REST API directly via fetch(). It works cleanly, the API surface is stable, and it's actually easier to read than SDK wrapper code.

5 steps to build Elasticsearch full-text search with Webflow Cloud

Elasticsearch handles the index and the query engine. The Webflow Cloud App handles query routing and response shaping. These steps build each piece in order: cluster configuration first, then the Route Handler, then the indexing script, then the frontend wire-up.

1. Create your Elastic Cloud cluster and define the index mapping

Sign in to Elastic Cloud and create a new deployment. I use the Elasticsearch preset (the default hardware tier is fine for development).

Once the deployment is ready, grab two values from the deployment dashboard:

  • The Elasticsearch endpoint (a URL ending in .elastic-cloud.com) and create an API key under Management → API Keys → Create API Key.
  • The API key Elastic Cloud gives you is already encoded. Put it directly in the Authorization: ApiKey {key} header with no additional encoding. No additional encoding needed.

With the cluster running, create the index with explicit field mappings.

Run this in Dev Tools → Console inside Kibana, or send it via curl from your terminal:

PUT /webflow-content
{
  "mappings": {
    "properties": {
      "title":           { "type": "text",    "boost": 3  },
      "slug":            { "type": "keyword"               },
      "body":            { "type": "text"                  },
      "category":        { "type": "keyword"               },
      "tags":            { "type": "keyword"               },
      "published_at":    { "type": "date"                  },
      "webflow_item_id": { "type": "keyword"               }
    }
  }
}

I always store webflow_item_id as a top-level field. When I reindex content, I use the Webflow item ID as the Elasticsearch document _id, which makes re-indexing idempotent. Running the script twice doesn't create duplicates; it just overwrites the existing document.

Title matches rank higher than body matches because the multi_match query in the Route Handler specifies title^3; a query-time boost that makes a title match worth three times a body match.

Query-time boosting is the correct approach in Elasticsearch 8.x; index-time field boosts were removed in 8.0 and will throw a MapperParsingException if included in a mapping.

Checkpoint: In Kibana Dev Tools, run GET /webflow-content and confirm the response shows your index with the mapping structure above.

2. Add Elasticsearch credentials to your Webflow Cloud environment

Your Cloud App needs three environment variables to talk to Elastic Cloud.

Add them to .env.local for local development:

# .env.local
ELASTIC_URL=https://your-cluster-id.es.us-east-1.aws.elastic-cloud.com
ELASTIC_API_KEY=your_encoded_api_key_from_elastic_cloud
ELASTIC_INDEX=webflow-content

In your Webflow site settings, open Webflow Cloud, select your environment, and add the same three variables under Environment Variables.

ELASTIC_API_KEY has write access to your index. Keep it out of client-side code and your git history. I use a separate read-only API key for the search Route Handler and a write-capable key only in the indexing script that runs locally. Two keys mean a leaked search key can't corrupt your index.

Checkpoint: Start your local dev server (next dev for Next.js, astro dev for Astro) and confirm process.env.ELASTIC_URL resolves to your cluster URL. Deploy and confirm that the same variable is available in the production environment.

3. Build the Elasticsearch search Route Handler

Create app/api/search/route.ts in your Cloud App. This handler receives a query string and optional filters from the frontend, builds an Elasticsearch query, and returns shaped results with highlights and category facets.

Here’s what that looks like:

// app/api/search/route.ts
export const runtime = 'edge'

const ELASTIC_URL = process.env.ELASTIC_URL!
const ELASTIC_API_KEY = process.env.ELASTIC_API_KEY!
const INDEX = process.env.ELASTIC_INDEX ?? 'webflow-content'

interface ElasticHit {
  _id: string
  _score: number
  _source: {
    title: string
    slug: string
    body: string
    category: string
    tags: string[]
    published_at: string
  }
  highlight?: { body?: string[] }
}

interface ElasticResponse {
  hits: {
    total: { value: number }
    hits: ElasticHit[]
  }
  aggregations?: {
    categories: { buckets: Array<{ key: string; doc_count: number }> }
  }
}

export async function GET(request: Request) {
  const url = new URL(request.url)
  const q = url.searchParams.get('q')?.trim() ?? ''
  const category = url.searchParams.get('category')
  const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1'))
  const size = 10
  const from = (page - 1) * size

  if (!q) {
    return Response.json({ results: [], total: 0, facets: {}, page })
  }

  // Build the query — multi_match across title, body, and tags with typo tolerance
  const must: unknown[] = [
    {
      multi_match: {
        query: q,
        fields: ['title^3', 'body', 'tags'],
        fuzziness: 'AUTO',
        minimum_should_match: '70%',
      },
    },
  ]

  // Apply optional category filter
  if (category) {
    must.push({ term: { category } })
  }

  const esQuery = {
    query: { bool: { must } },
    highlight: {
      fields: {
        body: { fragment_size: 150, number_of_fragments: 1 },
      },
      pre_tags: ['<mark>'],
      post_tags: ['</mark>'],
    },
    aggs: {
      categories: { terms: { field: 'category', size: 20 } },
    },
    size,
    from,
  }

  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 error = await esRes.text()
    console.error('Elasticsearch error:', error)
    return Response.json({ error: 'Search unavailable' }, { status: 502 })
  }

  const data = (await esRes.json()) as ElasticResponse

  const results = data.hits.hits.map((hit) => ({
    id: hit._id,
    score: hit._score,
    ...hit._source,
    highlight: hit.highlight?.body?.[0] ?? null,
  }))

  return Response.json({
    results,
    total: data.hits.total.value,
    facets: {
      categories: data.aggregations?.categories?.buckets ?? [],
    },
    page,
  })
}

I set minimum_should_match: '70%' because pure fuzziness: AUTO matches too broadly on short queries. A two-character query with AUTO fuzziness will match almost everything in the index. 70% means that at least 70% of the query terms must appear in a document, which yields much more useful results for real search inputs.

The highlight block returns a 150-character excerpt from the body with the matched term wrapped in <mark> tags. I always include this. Showing users where their search term appeared in the content is the difference between a useful search result and a list of titles.

Checkpoint: Start the dev server and run:

curl "http://localhost:3000/app/api/search?q=your-search-term"

You should get a JSON response with results, total, facets, and page fields. The results will be empty at this stage as the index has no content yet. Step 4 fixes that.

4. Index your Webflow CMS content into Elasticsearch

I run a local script to bulk-index CMS content. Create scripts/index-webflow-content.ts in your project root and run it from the command line when you need to refresh the index:

// 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 = process.env.ELASTIC_INDEX ?? 'webflow-content'

interface WebflowItem {
  id: string
  lastPublished: string
  fieldData: {
    name: string
    slug: string
    'post-body'?: 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()
    items.push(...data.items)

    if (items.length >= data.pagination.total) break
    offset += 100
  }

  return items
}

async function bulkIndex(items: WebflowItem[]): Promise<void> {
  // Elasticsearch bulk API requires alternating action + document lines
  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'] ?? '',
        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()
  const indexed = result.items.length
  const errors = result.items.filter((i: any) => i.index?.error).length

  console.log(`Indexed ${indexed} items. Errors: ${errors}`)
  if (errors > 0) {
    console.error('First error:', result.items.find((i: any) => i.index?.error)?.index?.error)
  }
}

const items = await fetchAllItems()
console.log(`Fetched ${items.length} items from Webflow`)
await bulkIndex(items)

The bulk API requires Content-Type: application/x-ndjson (newline-delimited JSON), not standard application/json. Each request alternates between an action line ({ index: { _index, _id } }) and a source document line. Missing the trailing newline at the end of the body causes a silent parse error. The + '\n' at the end of the join is required.

I keep a separate ELASTIC_WRITE_API_KEY in .env.local for this script. It never goes into the Webflow Cloud environment. The deployed search handler uses a read-only key.

Checkpoint: Run the script, then query the search Route Handler again. This time, the results should appear.

Step #5: Wire up the Webflow frontend search form

Add this to a Custom Code (Footer code: Before </body>) block on any Webflow page that has a search form. The form needs a text input with the data-search-input attribute and a results container with the data-search-results attribute.

(function () {
  const input = document.querySelector('[data-search-input]')
  const results = document.querySelector('[data-search-results]')
  const facetsEl = document.querySelector('[data-search-facets]')

  if (!input || !results) return

  let debounceTimer

  input.addEventListener('input', function () {
    clearTimeout(debounceTimer)
    debounceTimer = setTimeout(() => search(this.value.trim()), 300)
  })

  async function search(q, category = '') {
    if (!q) {
      results.innerHTML = ''
      return
    }

    const params = new URLSearchParams({ q })
    if (category) params.set('category', category)

    const res = await fetch(`/app/api/search?${params}`)
    const data = await res.json()

    renderResults(data.results, data.total)
    renderFacets(data.facets.categories ?? [])
  }

  function renderResults(items, total) {
    if (!items.length) {
      results.innerHTML = '<p>No results found.</p>'
      return
    }

    results.innerHTML = `
      <p>${total} result${total !== 1 ? 's' : ''}</p>
      ${items.map(item => `
        <div class="search-result">
          <a href="/${item.slug}"><strong>${item.title}</strong></a>
          ${item.highlight ? `<p>${item.highlight}</p>` : ''}
          ${item.category ? `<span class="category">${item.category}</span>` : ''}
        </div>
      `).join('')}
    `
  }

  function renderFacets(categories) {
    if (!facetsEl || !categories.length) return

    facetsEl.innerHTML = categories.map(cat => `
      <button data-category="${cat.key}" class="facet-btn">
        ${cat.key} (${cat.doc_count})
      </button>
    `).join('')

    facetsEl.querySelectorAll('.facet-btn').forEach(btn => {
      btn.addEventListener('click', function () {
        search(input.value.trim(), this.dataset.category)
      })
    })
  }
})()

The 300ms debounce on the input event means search fires once the user pauses typing, not on every keystroke. I always add this. Without it, a seven-character search generates seven API calls, and the results flicker as each response arrives at different times.

Checkpoint: Publish your Webflow site, type into the search input, and confirm results appear within the debounce window.

What causes Elasticsearch searches to fail on Webflow Cloud?

Most failures fall into three categories: the Node.js client being imported when it shouldn't be, CORS blocking the frontend fetch, and index mapping mismatches between the indexing script and the query.

The @elastic/elasticsearch client throws on import

If you see "Cannot find module 'net'" or "TypeError: net.createConnection is not a function, "you have the official Node.js client installed. Remove it with npm uninstall @elastic/elasticsearch and use the fetch()-based approach from Step 3.

The GitHub issue tracking Workers support (elastic/elasticsearch-js #2032) has no resolution date. The HTTP API covers every search and indexing operation you need.

Search returns results, but highlights are missing

Highlights require that the fields be stored as text in the index mapping. If you created the index before setting the mapping, Elasticsearch may have auto-detected your body field as keyword (which doesn't support full-text operations).

Run GET /webflow-content/_mapping in Kibana Dev Tools and verify body and title show "type": "text". If they don't, delete the index (DELETE /webflow-content), re-create it with the mapping from Step 1, and re-run the indexing script.

Bulk index script runs but produces zero results

The most common cause is a mismatch between the field name in the indexing script and the Webflow CMS field slug. Webflow generates field slugs from the field name. A field called "Post Body" uses the slug "post-body", not "body". Log item.fieldData for the first item in fetchAllItems() to inspect the actual field names, then update the body field mapping in the script.

What to build after Elasticsearch is running on your Webflow Cloud App

With a working search endpoint, the next thing I add is automatic re-indexing. Webflow sends a webhook on item publish. Wire it to a Route Handler that calls Elasticsearch's single-document index endpoint (PUT /{index}/_doc/{id}) to update the item. That way, the index stays up to date without having to run the bulk script manually after every content update.

Explore Webflow + Elasticsearch for the full integration overview, including authentication options and client library compatibility notes.

Frequently asked questions

Can I use Elasticsearch for site-wide search across multiple Webflow CMS collections?

Yes. Index each collection into the same Elasticsearch index with a collection_type field (e.g., "blog-post", "product", "help-article"). Add collection_type to the index mapping as a keyword field, then add it as a facet in the aggregation query. The search Route Handler returns one results list that spans all content types, with facets to filter by type.

How do I handle Webflow CMS pagination when indexing large content libraries?

The fetchAllItems() function in Step 4 handles this with an offset loop. The Webflow Data API returns up to 100 items per request and includes pagination.total in the response. The loop continues until items.length >= pagination.total. For collections with several thousand items, this may take a minute. Run the script in the background and monitor with console.log output.

Does the Elasticsearch free tier support everything in this guide?

Yes. Elastic Cloud's free 14-day trial includes full Elasticsearch functionality: multi-match queries, aggregations, highlights, and the bulk index API are all available. After the trial, the cheapest paid tier is sufficient for production search on a typical Webflow site. Check Elastic Cloud pricing for current tier details.

Should I use Elasticsearch or Algolia for Webflow search?

I reach for Elasticsearch when clients need full control over relevance tuning, custom scoring, or complex query structures, or when they're already running Elastic Stack for other purposes. I reach for Algolia when the priority is minimal setup time and an out-of-the-box managed relevance engine. Elasticsearch gives more power at the cost of more configuration. Both integrate the same way with Webflow Cloud: a Route Handler proxies queries and returns JSON.


Last Updated
May 22, 2026
Category

Related articles

How to embed an Instagram feed on Webflow and stop double-posting Instagram content
How to embed an Instagram feed on Webflow and stop double-posting Instagram content

How to embed an Instagram feed on Webflow and stop double-posting Instagram content

How to embed an Instagram feed on Webflow and stop double-posting Instagram content

Development
By
Colin Lateano
,
,
Read article
How to connect a Webflow form to Mailchimp without losing subscribers or breaking your design
How to connect a Webflow form to Mailchimp without losing subscribers or breaking your design

How to connect a Webflow form to Mailchimp without losing subscribers or breaking your design

How to connect a Webflow form to Mailchimp without losing subscribers or breaking your design

Development
By
Colin Lateano
,
,
Read article
How to send Webflow form submissions to HubSpot via Zapier without losing data
How to send Webflow form submissions to HubSpot via Zapier without losing data

How to send Webflow form submissions to HubSpot via Zapier without losing data

How to send Webflow form submissions to HubSpot via Zapier without losing data

Development
By
Colin Lateano
,
,
Read article
How to link Webflow forms to HubSpot without losing your form design
How to link Webflow forms to HubSpot without losing your form design

How to link Webflow forms to HubSpot without losing your form design

How to link Webflow forms to HubSpot without losing your form design

Development
By
Colin Lateano
,
,
Read article

verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.