Guide for building search frontends with Elastic's Search UI library. Use when a developer has a working Elasticsearch backend and needs a search page — results, facets, autocomplete, pagination. Connects to any recipe that produces a search API.
Guide developers through building a search frontend with Elastic's Search UI library (@elastic/search-ui). Use this guide after a backend recipe (keyword-search, hybrid-search, semantic-search, catalog-ecommerce) has produced a working index and API, and the developer asks "now how do I build the search page?"
Apply this guide when the developer signals:
Do not use this guide when:
Before starting this guide, the developer should have:
If they don't have a React project yet, scaffold one:
npx create-react-app my-search-app
cd my-search-app
Or with Next.js:
npx create-next-app@latest my-search-app
cd my-search-app
npm install @elastic/search-ui @elastic/react-search-ui @elastic/react-search-ui-views @elastic/search-ui-elasticsearch-connector
Three packages:
@elastic/search-ui — headless core (state, actions, query orchestration)@elastic/react-search-ui — React bindings (SearchProvider, useSearch hook)@elastic/react-search-ui-views — pre-built React components with default styling@elastic/search-ui-elasticsearch-connector — connects to Elasticsearch directlyThe connector is how Search UI talks to Elasticsearch. Setup depends on the deployment model.
For local development and prototyping, connect directly. Never use this in production — it exposes API credentials to the browser.
Elastic Cloud Hosted:
import ElasticsearchAPIConnector from "@elastic/search-ui-elasticsearch-connector";
const connector = new ElasticsearchAPIConnector({
cloud: {
id: "<your-cloud-id>"
},
index: "<your-index-name>",
apiKey: "<read-only-api-key>"
});
Find your Cloud ID in the Elastic Cloud console under your deployment's details.
Elastic Cloud Serverless:
const connector = new ElasticsearchAPIConnector({
host: "https://<your-project-id>.es.<region>.aws.elastic.cloud",
index: "<your-index-name>",
apiKey: "<read-only-api-key>"
});
Use the Elasticsearch endpoint from your Serverless project settings. Serverless projects use host (not cloud.id).
Self-managed:
const connector = new ElasticsearchAPIConnector({
host: "http://localhost:9200",
index: "<your-index-name>",
apiKey: "<read-only-api-key>"
});
CORS for direct browser connections: If connecting directly from the browser (development only), Elasticsearch needs CORS headers. In Elastic Cloud Hosted, go to deployment settings → Edit user settings and add:
http.cors.allow-origin: "*"
http.cors.enabled: true
http.cors.allow-credentials: true
http.cors.allow-methods: OPTIONS, HEAD, GET, POST, PUT, DELETE
http.cors.allow-headers: X-Requested-With, X-Auth-Token, Content-Type, Content-Length, Authorization, Access-Control-Allow-Headers, Accept, x-elastic-client-meta
Serverless projects handle CORS automatically. Self-managed clusters need the same settings in elasticsearch.yml.
In production, proxy all requests through your backend. This avoids exposing credentials, lets you add caching, logging, and access control.
Frontend (browser):
import { ApiProxyConnector } from "@elastic/search-ui-elasticsearch-connector/api-proxy";
const connector = new ApiProxyConnector({
basePath: "/api"
});
Backend (Express server):
import express from "express";
import ElasticsearchAPIConnector from "@elastic/search-ui-elasticsearch-connector";
const app = express();
app.use(express.json());
const connector = new ElasticsearchAPIConnector({
host: "<your-elasticsearch-endpoint>",
index: "<your-index-name>",
apiKey: "<your-api-key>"
});
app.post("/api/search", async (req, res) => {
const { state, queryConfig } = req.body;
const response = await connector.onSearch(state, queryConfig);
res.json(response);
});
app.post("/api/autocomplete", async (req, res) => {
const { state, queryConfig } = req.body;
const response = await connector.onAutocomplete(state, queryConfig);
res.json(response);
});
app.listen(3001);
This pattern works identically for Hosted, Serverless, and self-managed — the only difference is how you configure the server-side connector (cloud.id vs host).
Next.js has built-in API routes, so you don't need a separate Express server.
Frontend connector (services/SearchConnector.js):
class SearchConnector {
onResultClick() {}
onAutocompleteResultClick() {}
async onSearch(requestState, queryConfig) {
const response = await fetch("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requestState, queryConfig })
});
return response.json();
}
async onAutocomplete(requestState, queryConfig) {
const response = await fetch("/api/autocomplete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requestState, queryConfig })
});
return response.json();
}
}
export default SearchConnector;
API routes (pages/api/search.js and pages/api/autocomplete.js):
import ElasticsearchAPIConnector from "@elastic/search-ui-elasticsearch-connector";
const connector = new ElasticsearchAPIConnector({
host: "<your-elasticsearch-endpoint>",
index: "<your-index-name>",
apiKey: "<your-api-key>"
});
export default async function handler(req, res) {
const { requestState, queryConfig } = req.body;
const response = await connector.onSearch(requestState, queryConfig);
res.json(response);
}
The configuration object tells Search UI which fields to search, which fields to show, and how to build facets. This must match the index mapping created in the backend recipe.
The configuration fields map directly to the Elasticsearch index mapping. Here's how to translate:
| Index Mapping | Search UI Config |
|---|---|
text fields you want searchable | search_fields with optional weight |
| Fields to display in results | result_fields with raw or snippet |
keyword fields for filtering | facets with type: "value" |
| Numeric fields for range filters | facets with type: "range" and ranges array |
geo_point fields | facets with type: "range", center, and unit |
completion or search_as_you_type fields | autocompleteQuery.suggestions |
This matches the index mapping from the catalog-ecommerce recipe:
const config = {
apiConnector: connector,
alwaysSearchOnInitialLoad: true,
searchQuery: {
search_fields: {
title: { weight: 3 },
description: {},
brand: { weight: 2 },
tags: {}
},
result_fields: {
title: { snippet: { size: 100, fallback: true } },
description: { snippet: { size: 200, fallback: true } },
brand: { raw: {} },
price: { raw: {} },
rating: { raw: {} },
image_url: { raw: {} },
category: { raw: {} }
},
fuzziness: true,
disjunctiveFacets: ["category", "brand"],
facets: {
category: { type: "value", size: 20 },
brand: { type: "value", size: 20 },
price: {
type: "range",
ranges: [
{ from: 0, to: 25, name: "Under $25" },
{ from: 25, to: 50, name: "$25–$50" },
{ from: 50, to: 100, name: "$50–$100" },
{ from: 100, to: 200, name: "$100–$200" },
{ from: 200, name: "$200+" }
]
},
rating: {
type: "range",
ranges: [
{ from: 4, name: "4+ stars" },
{ from: 3, to: 4, name: "3–4 stars" },
{ from: 0, to: 3, name: "Under 3 stars" }
]
}
}
},
autocompleteQuery: {
results: {
resultsPerPage: 5,
search_fields: {
"title.autocomplete": { weight: 3 }
},
result_fields: {
title: { snippet: { size: 100, fallback: true } },
price: { raw: {} },
image_url: { raw: {} }
}
},
suggestions: {
types: {
documents: { fields: ["title_suggest"] }
},
size: 4
}
}
};
disjunctiveFacets — list fields here if you want facet counts to stay visible after a selection. Without this, selecting "Electronics" as a category would hide all other category options. With it, the user can see counts for other categories and add more selections.
fuzziness: true — enables typo tolerance. Internally maps to Elasticsearch's fuzziness: "AUTO".
Autocomplete has two modes that require different field types in the mapping:
| Mode | What It Does | Required Mapping |
|---|---|---|
results | Shows matching documents as you type | search_as_you_type field (best) or any text field |
suggestions | Shows suggested query terms | completion field |
If the backend recipe already created completion or search_as_you_type fields, reference them here. If not, the developer will need to update the mapping and reindex.
import React from "react";
import {
SearchProvider,
SearchBox,
Results,
PagingInfo,
ResultsPerPage,
Paging,
Facet,
Sorting,
ErrorBoundary
} from "@elastic/react-search-ui";
import { Layout } from "@elastic/react-search-ui-views";
import "@elastic/react-search-ui-views/lib/styles/styles.css";
export default function SearchPage() {
return (
<SearchProvider config={config}>
<div className="App">
<ErrorBoundary>
<Layout
header={
<SearchBox
autocompleteResults={{
titleField: "title",
urlField: "url",
sectionTitle: "Results"
}}
autocompleteSuggestions={true}
debounceLength={300}
/>
}
sideContent={
<div>
<Facet field="category" label="Category" />
<Facet field="brand" label="Brand" />
<Facet field="price" label="Price" />
<Facet field="rating" label="Rating" />
</div>
}
bodyContent={<Results shouldTrackClickThrough />}
bodyHeader={
<>
<PagingInfo />
<ResultsPerPage options={[10, 20, 50]} />
<Sorting
label="Sort by"
sortOptions={[
{ name: "Relevance", value: [] },
{ name: "Price: Low to High", value: [{ field: "price", direction: "asc" }] },
{ name: "Price: High to Low", value: [{ field: "price", direction: "desc" }] },
{ name: "Rating", value: [{ field: "rating", direction: "desc" }] }
]}
/>
</>
}
bodyFooter={<Paging />}
/>
</ErrorBoundary>
</div>
</SearchProvider>
);
}
| Component | Purpose | Key Props |
|---|---|---|
SearchBox | Search input with autocomplete | autocompleteResults, autocompleteSuggestions, searchAsYouType, debounceLength |
Results | Render search result list | shouldTrackClickThrough, custom view |
Result | Single result card | titleField, urlField, custom view |
Facet | Sidebar filter | field, label, filterType ("any", "all", "none") |
Sorting | Sort dropdown | sortOptions array |
Paging | Page navigation | - |
PagingInfo | "Showing 1-10 of 250 results" | - |
ResultsPerPage | Results per page selector | options array |
ErrorBoundary | Catches and displays errors | - |
Layout | Pre-built page layout | header, sideContent, bodyContent, bodyHeader, bodyFooter |
Override the default result view to show product cards, images, or custom layouts:
<Results
resultView={({ result }) => (
<div className="product-card">
{result.image_url?.raw && (
<img src={result.image_url.raw} alt={result.title?.raw} />
)}
<h3 dangerouslySetInnerHTML={{ __html: result.title?.snippet || result.title?.raw }} />
<p className="price">${result.price?.raw}</p>
<p className="brand">{result.brand?.raw}</p>
{result.rating?.raw && <span>{"★".repeat(Math.round(result.rating.raw))}</span>}
</div>
)}
/>
Search UI ships with default CSS. Import it to get a working layout immediately:
import "@elastic/react-search-ui-views/lib/styles/styles.css";
Override with your own CSS using the sui- class prefix (e.g., .sui-search-box, .sui-facet, .sui-result). Every component also accepts a className prop.
Search UI's default query works for keyword search. For semantic, hybrid, or advanced queries, use getQueryFn to override the query generation.
semantic_text field)const connector = new ElasticsearchAPIConnector({
// ... connection config ...
getQueryFn: (state, config) => ({
semantic: {
field: "content_semantic",
query: state.searchTerm
}
})
});
For hybrid search, use interceptSearchRequest to inject a retriever-based query:
const connector = new ElasticsearchAPIConnector({
// ... connection config ...
interceptSearchRequest: async ({ requestBody, requestState, queryConfig }, next) => {
if (!requestState.searchTerm) return next(requestBody);
const modifiedBody = {
...requestBody,
query: undefined,
retriever: {
rrf: {
retrievers: [
{
standard: {
query: {
multi_match: {
query: requestState.searchTerm,
fields: ["title^3", "description"]
}
}
}
},
{
standard: {
query: {
semantic: {
field: "content_semantic",
query: requestState.searchTerm
}
}
}
}
]
}
}
};
return next(modifiedBody);
}
});
const connector = new ElasticsearchAPIConnector({
// ... connection config ...
getQueryFn: (state, config) => ({
sparse_vector: {
field: "content_embedding",
inference_id: ".elser-2-elasticsearch",
query: state.searchTerm
}
})
});
| Strategy | Minimum ES Version | Notes |
|---|---|---|
| Keyword (multi_match, bool) | Any modern version | Works everywhere |
Semantic (semantic query) | 8.15+ | Requires semantic_text field |
| kNN | 8.0+ | Use dense_vector field |
| Sparse vector / ELSER | 8.11+ | ELSER model must be deployed |
| Hybrid with RRF retrievers | 8.14+ | Retriever syntax |
| Serverless | Always latest | All features available |
When generating code, check the developer's Elasticsearch version (confirmed in the main playbook's Step 3) and only recommend query strategies their version supports.
@elastic/react-search-ui-views) are React-only. The headless core works with any framework, but you must build your own UI components.disjunctiveFacets, selecting a facet value hides other options. Always list filterable facets in disjunctiveFacets for a good UX.| Question | Answer |
|---|---|
| "How do I change the result layout?" | Pass a custom resultView function to <Results>. You get the full result object and render whatever JSX you want. |
| "How do I add more filters?" | Add the field to facets in the config, then add a <Facet field="..." label="..." /> component. The field must be keyword type in the mapping. |
| "How do I make facets stay open after selection?" | Add the field name to the disjunctiveFacets array. |
| "How do I style it?" | Import the default CSS, then override with your own styles using .sui-* selectors. Or pass className to any component. |
| "How do I deploy this?" | Switch from ElasticsearchAPIConnector to ApiProxyConnector on the frontend. Run the proxy server (Express or Next.js API routes) alongside your app. |
| "Can I use this with Vue/Angular?" | The headless core (@elastic/search-ui) works with any framework. You'll need to build your own components instead of using the React ones. Wire up state via SearchDriver directly. |
| "Can I add search analytics?" | Use the onResultClick and onSearch event hooks to send events to your analytics service. |
| "How do I add semantic search?" | Use getQueryFn to override the default query with a semantic query (requires ES 8.15+ and a semantic_text field). See section 7. |
This guide is designed to plug into any backend recipe. Here's how the handoff works:
| Backend Recipe | What It Provides | Search UI Connects Via |
|---|---|---|
| keyword-search | Text fields, keyword filters, completion field | Default query config — just map search_fields and facets to the index |
| catalog-ecommerce | Product mapping with synonyms, nested attributes, autocomplete | Full config example in section 5 above |
| hybrid-search | BM25 + semantic fields | getQueryFn or interceptSearchRequest with RRF retriever (section 7) |
| semantic-search | semantic_text or dense_vector fields | getQueryFn with semantic or knn query (section 7) |
The backend recipe builds the index, mapping, ingestion, and API. This recipe builds the frontend on top. If the developer followed a backend recipe that produced a Flask/Express API, they have two choices:
For option 2, implement a custom connector:
class CustomAPIConnector {
async onSearch(requestState, queryConfig) {
const response = await fetch("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: requestState.searchTerm,
filters: requestState.filters,
page: requestState.current,
size: requestState.resultsPerPage,
sort: requestState.sortField
})
});
const data = await response.json();
// Transform your API response to Search UI's expected format
return {
results: data.hits.map(hit => ({ ...hit, id: { raw: hit.id } })),
totalResults: data.total,
facets: data.facets || {}
};
}
async onAutocomplete(requestState, queryConfig) {
// Similar implementation for autocomplete
}
onResultClick() {}
onAutocompleteResultClick() {}
}