Skip to content

Build a Headless Search Experience

⏱ 90 minutes advanced
📜CoreGraph

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.

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.

Initialize the project
bash
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

Build a reusable function to send queries to Graph. This client handles authentication and error responses.

src/graphClient.js
javascript
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;
}

Define a GraphQL query that searches content and returns results with scores.

src/queries.js
javascript
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.

src/SearchResults.jsx
javascript
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.

src/SearchInput.jsx
javascript
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',
      }}
    />
  );
}

Connect the input, client, and results into a working search page.

src/App.jsx
javascript
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.

Extend the search query to include facets and build a filter sidebar.

Faceted search query and component
javascript
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
        }
      }
    }
  }
`;
javascript
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>
  );
}

Add cursor-based pagination with a “Load More” button.

Pagination support
javascript
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>
  );
}

Build a type-ahead component that suggests content as the user types.

Autocomplete component
javascript
export const AUTOCOMPLETE_QUERY = `
  query Autocomplete($prefix: String!) {
    ArticlePage(
      where: {
        _fulltext: {
          _autocomplete: { contains: $prefix }
        }
      }
      limit: 5
    ) {
      items {
        Headline
        _metadata { url { default } }
      }
    }
  }
`;
javascript
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>
  );
}

Switch to semantic ranking to return results based on meaning rather than keyword frequency.

Semantic search query
javascript
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.

Search mode toggle
javascript
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.

Before deploying, apply these optimizations:

  1. Register saved query templates — Convert your GraphQL queries to persisted queries for smaller payloads and better caching. See Use Cached Templates.

  2. Add error boundaries — Wrap search components in React error boundaries to handle Graph API failures gracefully.

  3. Implement result caching — Cache recent search results client-side to avoid duplicate API calls when users navigate back.

  4. Add loading skeletons — Replace the “Searching…” text with skeleton UI that matches the results layout.

  5. Track search analytics — Log search terms, result counts, and click-through positions to identify content gaps and improve search quality.

Test these scenarios to confirm everything works:

ScenarioExpected result
Enter a search termResults appear with relevance scores
Click a category facetResults filter to that category, counts update
Clear the category filterAll results return
Type 2+ characters slowlyAutocomplete suggestions appear
Switch to semantic modeResults re-rank by meaning similarity
Click “Load More”Additional results append to the list