Build a Headless Search Experience
What you will build
Section titled “What you will build”By the end of this tutorial, you will have a React application that:
- Connects to Optimizely Graph and queries content
- Displays search results with relevance scoring
- Provides faceted filtering by category and date
- Implements type-ahead autocomplete
- Uses semantic search for meaning-based results
This tutorial uses a standard React app with no additional UI framework. Adapt the patterns to Next.js, Remix, or any React-based framework.
Before you start
Section titled “Before you start”Ensure you have your Graph credentials ready:
- Graph URL:
https://cg.optimizely.com/content/v2 - SingleKey: Found in the Optimizely portal under Graph settings
Your Graph instance should have content indexed. If you have not set up Graph yet, complete Set Up Graph first.
Step 1: Create the React project
Section titled “Step 1: Create the React project”npm create vite@latest graph-search -- --template react
cd graph-search
npm install
# Create an environment file for credentials
echo 'VITE_GRAPH_URL=https://cg.optimizely.com/content/v2' > .env
echo 'VITE_GRAPH_SINGLE_KEY=your-single-key-here' >> .env Step 2: Create the Graph client
Section titled “Step 2: Create the Graph client”Build a reusable function to send queries to Graph. This client handles authentication and error responses.
const GRAPH_URL = import.meta.env.VITE_GRAPH_URL;
const SINGLE_KEY = import.meta.env.VITE_GRAPH_SINGLE_KEY;
export async function graphFetch(query, variables = {}) {
const response = await fetch(GRAPH_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${SINGLE_KEY}`,
},
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
throw new Error(`Graph API error: ${response.status}`);
}
const result = await response.json();
if (result.errors) {
throw new Error(result.errors[0].message);
}
return result.data;
} Step 3: Write your first search query
Section titled “Step 3: Write your first search query”Define a GraphQL query that searches content and returns results with scores.
export const SEARCH_QUERY = `
query SearchContent(
$searchTerm: String!
$limit: Int = 10
$cursor: String
) {
ArticlePage(
where: {
_fulltext: { contains: $searchTerm }
_metadata: { status: { eq: "Published" } }
}
orderBy: { _ranking: RELEVANCE }
limit: $limit
cursor: $cursor
) {
items {
Headline
Summary
Author
Category
_score
_metadata {
published
url { default }
}
}
total
cursor
}
}
`; Step 4: Build the search results component
Section titled “Step 4: Build the search results component”Create a component that displays search results with relevance scores and metadata.
export function SearchResults({ items, total }) {
if (!items || items.length === 0) {
return <p>No results found.</p>;
}
return (
<div>
<p>{total} results found</p>
<ul style={{ listStyle: 'none', padding: 0 }}>
{items.map((item) => (
<li key={item._metadata.url.default}
style={{ marginBottom: '1.5rem' }}>
<a href={item._metadata.url.default}>
<h3>{item.Headline}</h3>
</a>
{item.Summary && <p>{item.Summary}</p>}
<small>
{item.Author} | {item.Category} |
Score: {item._score?.toFixed(2)}
</small>
</li>
))}
</ul>
</div>
);
} Step 5: Create the search input with debouncing
Section titled “Step 5: Create the search input with debouncing”Add a search input that waits for the user to stop typing before sending a query.
import { useState, useCallback, useRef } from 'react';
export function SearchInput({ onSearch }) {
const [value, setValue] = useState('');
const timerRef = useRef(null);
const handleChange = useCallback((e) => {
const newValue = e.target.value;
setValue(newValue);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
if (newValue.length >= 2) {
onSearch(newValue);
}
}, 300);
}, [onSearch]);
return (
<input
type="search"
value={value}
onChange={handleChange}
placeholder="Search articles..."
style={{
width: '100%', padding: '0.75rem',
fontSize: '1.1rem', border: '1px solid #ccc',
borderRadius: '4px',
}}
/>
);
} Step 6: Wire up the main search page
Section titled “Step 6: Wire up the main search page”Connect the input, client, and results into a working search page.
import { useState } from 'react';
import { graphFetch } from './graphClient';
import { SEARCH_QUERY } from './queries';
import { SearchInput } from './SearchInput';
import { SearchResults } from './SearchResults';
export default function App() {
const [results, setResults] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
async function handleSearch(term) {
setLoading(true);
setError(null);
try {
const data = await graphFetch(
SEARCH_QUERY, { searchTerm: term }
);
setResults(data.ArticlePage);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
return (
<div style={{ maxWidth: '800px', margin: '2rem auto' }}>
<h1>Content Search</h1>
<SearchInput onSearch={handleSearch} />
{loading && <p>Searching...</p>}
{error && <p style={{ color: 'red' }}>{error}</p>}
{results && (
<SearchResults
items={results.items}
total={results.total}
/>
)}
</div>
);
} Test the application: run npm run dev and enter a search term. You should see results from your Graph instance.
Step 7: Add faceted filtering
Section titled “Step 7: Add faceted filtering”Extend the search query to include facets and build a filter sidebar.
export const FACETED_SEARCH_QUERY = `
query FacetedSearch(
$searchTerm: String!
$category: String
$limit: Int = 10
) {
ArticlePage(
where: {
_fulltext: { contains: $searchTerm }
_metadata: { status: { eq: "Published" } }
Category: { eq: $category }
}
orderBy: { _ranking: RELEVANCE }
limit: $limit
) {
items {
Headline
Summary
Author
Category
_score
_metadata { url { default } published }
}
total
facets {
Category(limit: 10, orderBy: COUNT, orderType: DESC) {
name
count
}
}
}
}
`; export function FacetSidebar({ facets, selected, onSelect }) {
if (!facets || facets.length === 0) return null;
return (
<aside style={{ minWidth: '200px' }}>
<h3>Categories</h3>
<ul style={{ listStyle: 'none', padding: 0 }}>
<li>
<button
onClick={() => onSelect(null)}
style={{
fontWeight: !selected ? 'bold' : 'normal'
}}>
All
</button>
</li>
{facets.map((facet) => (
<li key={facet.name}>
<button
onClick={() => onSelect(facet.name)}
style={{
fontWeight: selected === facet.name
? 'bold' : 'normal'
}}>
{facet.name} ({facet.count})
</button>
</li>
))}
</ul>
</aside>
);
} Step 8: Implement pagination
Section titled “Step 8: Implement pagination”Add cursor-based pagination with a “Load More” button.
function SearchPage() {
const [results, setResults] = useState(null);
const [cursor, setCursor] = useState(null);
async function loadMore() {
const data = await graphFetch(
SEARCH_QUERY,
{ searchTerm: currentTerm, cursor }
);
setResults((prev) => ({
...data.ArticlePage,
items: [...prev.items, ...data.ArticlePage.items],
}));
setCursor(data.ArticlePage.cursor);
}
return (
<div>
<SearchResults
items={results?.items}
total={results?.total}
/>
{cursor && results?.items.length < results?.total && (
<button onClick={loadMore}>Load More</button>
)}
</div>
);
} Step 9: Add autocomplete
Section titled “Step 9: Add autocomplete”Build a type-ahead component that suggests content as the user types.
export const AUTOCOMPLETE_QUERY = `
query Autocomplete($prefix: String!) {
ArticlePage(
where: {
_fulltext: {
_autocomplete: { contains: $prefix }
}
}
limit: 5
) {
items {
Headline
_metadata { url { default } }
}
}
}
`; import { useState, useRef } from 'react';
import { graphFetch } from './graphClient';
import { AUTOCOMPLETE_QUERY } from './queries';
export function Autocomplete({ onSelect }) {
const [suggestions, setSuggestions] = useState([]);
const [query, setQuery] = useState('');
const timerRef = useRef(null);
function handleInput(e) {
const value = e.target.value;
setQuery(value);
clearTimeout(timerRef.current);
if (value.length < 2) {
setSuggestions([]);
return;
}
timerRef.current = setTimeout(async () => {
const data = await graphFetch(
AUTOCOMPLETE_QUERY, { prefix: value }
);
setSuggestions(data.ArticlePage.items);
}, 150);
}
return (
<div style={{ position: 'relative' }}>
<input
value={query}
onChange={handleInput}
placeholder="Start typing..."
/>
{suggestions.length > 0 && (
<ul style={{
position: 'absolute', top: '100%',
background: 'white', border: '1px solid #ccc',
width: '100%', listStyle: 'none', padding: 0,
}}>
{suggestions.map((s) => (
<li key={s._metadata.url.default}
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => {
onSelect(s.Headline);
setSuggestions([]);
setQuery(s.Headline);
}}>
{s.Headline}
</li>
))}
</ul>
)}
</div>
);
} Step 10: Enable semantic search
Section titled “Step 10: Enable semantic search”Switch to semantic ranking to return results based on meaning rather than keyword frequency.
export const SEMANTIC_SEARCH_QUERY = `
query SemanticSearch($searchTerm: String!, $limit: Int = 10) {
ArticlePage(
where: {
_fulltext: { contains: $searchTerm }
_metadata: { status: { eq: "Published" } }
}
orderBy: { _ranking: SEMANTIC }
limit: $limit
) {
items {
Headline
Summary
_score
_metadata { url { default } }
}
total
}
}
`; Add a toggle in the UI to let users switch between keyword and semantic search modes.
Step 11: Add a search mode toggle
Section titled “Step 11: Add a search mode toggle”function SearchModeToggle({ mode, onChange }) {
return (
<div style={{ margin: '1rem 0' }}>
<label>
<input
type="radio"
name="mode"
value="keyword"
checked={mode === 'keyword'}
onChange={() => onChange('keyword')}
/>
Keyword Search
</label>
<label style={{ marginLeft: '1rem' }}>
<input
type="radio"
name="mode"
value="semantic"
checked={mode === 'semantic'}
onChange={() => onChange('semantic')}
/>
Semantic Search
</label>
</div>
);
} Use the mode state to select between SEARCH_QUERY (keyword with RELEVANCE ranking) and SEMANTIC_SEARCH_QUERY (with SEMANTIC ranking) when calling graphFetch.
Step 12: Optimize for production
Section titled “Step 12: Optimize for production”Before deploying, apply these optimizations:
-
Register saved query templates — Convert your GraphQL queries to persisted queries for smaller payloads and better caching. See Use Cached Templates.
-
Add error boundaries — Wrap search components in React error boundaries to handle Graph API failures gracefully.
-
Implement result caching — Cache recent search results client-side to avoid duplicate API calls when users navigate back.
-
Add loading skeletons — Replace the “Searching…” text with skeleton UI that matches the results layout.
-
Track search analytics — Log search terms, result counts, and click-through positions to identify content gaps and improve search quality.
Verify the completed application
Section titled “Verify the completed application”Test these scenarios to confirm everything works:
| Scenario | Expected result |
|---|---|
| Enter a search term | Results appear with relevance scores |
| Click a category facet | Results filter to that category, counts update |
| Clear the category filter | All results return |
| Type 2+ characters slowly | Autocomplete suggestions appear |
| Switch to semantic mode | Results re-rank by meaning similarity |
| Click “Load More” | Additional results append to the list |