> ## Documentation Index
> Fetch the complete documentation index at: https://docs.glood.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Search Example

> Complete working example: a custom search page with instant search, results, filters, image search and attribution

# Search Example

A complete, copy-pasteable search experience built on the Glood Hydrogen Search SDK:
config → instant search → full results → filters → image search → attribution events. Take
the pieces you need.

## 1. Client Setup

Register the search app once (alongside recommendations if you use it):

```tsx theme={null}
// app/lib/glood.ts
import { createGlood, search } from '@glood/hydrogen';

export const glood = createGlood({
  apiKey: 'gl_sf_your_storefront_token',
  myShopifyDomain: 'your-store.myshopify.com',
  debug: import.meta.env.DEV,
}).use(search());
```

Wrap your app in `root.tsx` (inside Shopify's `Analytics.Provider`):

```tsx theme={null}
// app/root.tsx
import { Analytics } from '@shopify/hydrogen';
import { GloodProvider } from '@glood/hydrogen';
import { glood } from '~/lib/glood';

export default function App() {
  const data = useRouteLoaderData('root');
  return (
    <Analytics.Provider cart={data.cart} shop={data.shop} consent={data.consent}>
      <GloodProvider client={glood} loaderData={data}>
        <PageLayout {...data}>
          <Outlet />
        </PageLayout>
      </GloodProvider>
    </Analytics.Provider>
  );
}
```

## 2. Don't Forget the CSP

Search fetches and events are blocked by Hydrogen's default CSP. Add the search origins in
`entry.server`:

```tsx theme={null}
const { nonce, header, NonceProvider } = createContentSecurityPolicy({
  shop: {
    checkoutDomain: context.env.PUBLIC_CHECKOUT_DOMAIN,
    storeDomain: context.env.PUBLIC_STORE_DOMAIN,
  },
  connectSrc: [
    "'self'",
    'https://storefront.glood.ai', // recommendations (if used)
    'https://search.glood.ai',        // search v1 API + events
  ],
});
```

## 3. Instant Search Box

Type-ahead search with suggestions, autocorrect and a trending empty state:

```tsx theme={null}
// app/components/GloodSearchBox.tsx
import { useState } from 'react';
import { useInstantSearch } from '@glood/hydrogen';

export function GloodSearchBox() {
  const [term, setTerm] = useState('');
  const { products, suggestions, data, loading } = useInstantSearch(
    { query: term, objects: ['product', 'collection', 'query_suggestion', 'autocorrect'] },
    { skip: term.length < 2 },
  );

  return (
    <div className="search-box">
      <input
        value={term}
        onChange={(e) => setTerm(e.target.value)}
        placeholder="Search products…"
      />

      {loading && <span className="spinner" />}

      {data?.corrected_query && (
        <button onClick={() => setTerm(data.corrected_query!)}>
          Did you mean <strong>{data.corrected_query}</strong>?
        </button>
      )}

      {suggestions.length > 0 && (
        <ul className="suggestions">
          {suggestions.map((s) => (
            <li key={s.text} onClick={() => setTerm(s.text)}>
              {s.text}
            </li>
          ))}
        </ul>
      )}

      <div className="instant-grid">
        {products.map((p) => (
          <a key={p.product_id} href={`/products/${p.handle}`}>
            <img src={p.image?.url} alt={p.title} loading="lazy" />
            <span>{p.title}</span>
            <span>{p.display_price ?? p.price}</span>
          </a>
        ))}
      </div>

      {term.length < 2 && data?.trending?.searches?.length ? (
        <div className="trending">
          <h4>Trending</h4>
          {data.trending.searches.map((t) => (
            <button key={t.term} onClick={() => setTerm(t.term)}>
              {t.translated_term ?? t.term}
            </button>
          ))}
        </div>
      ) : null}
    </div>
  );
}
```

## 4. Full Search Results Page

A results page with filters, sort, pagination, and click/add-to-cart attribution:

```tsx theme={null}
// app/routes/search.tsx
import { useState } from 'react';
import { useSearchParams } from 'react-router';
import { useSearch, useGloodAnalytics } from '@glood/hydrogen';

export default function SearchPage() {
  const [searchParams] = useSearchParams();
  const query = searchParams.get('q') ?? '';
  const [page, setPage] = useState(1);
  const [selectedColors, setSelectedColors] = useState<string[]>([]);

  const facets = selectedColors.length
    ? [{ key: 'color', operator: 'or' as const, value: selectedColors }]
    : [];

  const {
    products,
    data,
    loading,
    error,
    trackSearch,
    trackResultClick,
    trackAddToCart,
    trackFilter,
  } = useSearch({
    query,
    facets,
    sort: 'relevance',
    pagination: { page, limit: 24 },
    options: { includeFacets: true },
  });

  if (error) return <p>Search failed: {error.message}</p>;

  const buildTrack = (product?: { product_id: number | string }) => ({
    request_id: data?.request_id,
    search_term: query,
    search_type: 'text',
    source: 'search_page',
    ...(product ? { products: [{ product_id: product.product_id }] } : {}),
    ...(selectedColors.length ? { filters: [{ key: 'color', value: selectedColors }] } : {}),
  });

  return (
    <div className="search-page" ref={() => trackSearch(buildTrack())}>
      <aside className="filters">
        {data?.facets?.map((facet) => (
          <fieldset key={facet.key}>
            <legend>{facet.key}</legend>
            {facet.aggregation.buckets.map((b) => (
              <label key={b.value}>
                <input
                  type="checkbox"
                  checked={b.selected}
                  onChange={() => {
                    const next = b.selected
                      ? selectedColors.filter((c) => c !== b.value)
                      : [...selectedColors, b.value];
                    setSelectedColors(next);
                    setPage(1);
                    trackFilter({ ...buildTrack(), filters: [{ key: facet.key, value: next }] });
                  }}
                />
                {b.value} ({b.count})
              </label>
            ))}
          </fieldset>
        ))}
      </aside>

      <section className="results">
        {loading ? (
          <p>Searching…</p>
        ) : (
          <>
            <p>{data?.pagination.total} results for “{query}”</p>
            <div className="grid">
              {products.map((product) => (
                <div key={product.product_id} className="card">
                  <a
                    href={`/products/${product.handle}`}
                    onClick={() => trackResultClick(buildTrack(product))}
                  >
                    <img src={product.image?.url} alt={product.title} loading="lazy" />
                    <h3>{product.title}</h3>
                    <p>{product.display_price ?? product.price}</p>
                  </a>
                  <button onClick={() => trackAddToCart(buildTrack(product))}>
                    Add to cart
                  </button>
                </div>
              ))}
            </div>

            <nav className="pagination">
              <button
                disabled={!data?.pagination.has_prev_page}
                onClick={() => setPage((p) => p - 1)}
              >
                Previous
              </button>
              <span>
                Page {data?.pagination.page} / {data?.pagination.total_pages}
              </span>
              <button
                disabled={!data?.pagination.has_next_page}
                onClick={() => setPage((p) => p + 1)}
              >
                Next
              </button>
            </nav>
          </>
        )}
      </section>
    </div>
  );
}
```

## 5. Image (Visual) Search

Search by an uploaded photo:

```tsx theme={null}
// app/components/GloodImageSearch.tsx
import { useState } from 'react';
import { useGloodAnalytics, type SearchItem } from '@glood/hydrogen';

const toDataUrl = (file: File) =>
  new Promise<string>((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result as string);
    reader.onerror = reject;
    reader.readAsDataURL(file);
  });

export function GloodImageSearch() {
  const app = useGloodAnalytics()?.getApp('search');
  const [products, setProducts] = useState<SearchItem[]>([]);

  async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file || !app) return;
    // jpeg/png/webp only, 1KB–5MB, 100×100 to 4096×4096
    const res = await app.imageSearch({ image: await toDataUrl(file), pagination: { limit: 24 } });
    setProducts(res.products);
  }

  return (
    <div>
      <input type="file" accept="image/jpeg,image/png,image/webp" onChange={onFile} />
      <div className="grid">
        {products.map((p) => (
          <a key={p.product_id} href={`/products/${p.handle}`}>
            <img src={p.image?.url} alt={p.title} />
            <span>{p.title}</span>
          </a>
        ))}
      </div>
    </div>
  );
}
```

## 6. Server-Side Rendering Variant

To ship results in the initial HTML, fetch in the loader instead of the hook:

```tsx theme={null}
// app/routes/search.tsx
import { fetchSearchResults } from '@glood/hydrogen';

export async function loader({ request, context }) {
  const query = new URL(request.url).searchParams.get('q') ?? '';

  const results = await fetchSearchResults(
    {
      apiKey: context.env.GLOOD_API_KEY,
      myShopifyDomain: 'your-store.myshopify.com',
    },
    { query, pagination: { page: 1, limit: 24 } },
  ).catch(() => null); // degrade gracefully

  return { query, results };
}

export default function SearchPage() {
  const { results } = useLoaderData();
  return (
    <div className="grid">
      {results?.products.map((product) => (
        <a key={product.product_id} href={`/products/${product.handle}`}>
          <img src={product.image?.url} alt={product.title} />
          <span>{product.title}</span>
        </a>
      ))}
    </div>
  );
}
```

<Note>
  With SSR you still get attribution: retrieve the track helpers client-side from `useSearch`
  (pass `{ skip: true }` to reuse them without a second fetch), or send events directly with
  `sendSearchEvent(options, event)`.
</Note>

## 7. Standard Events Are Automatic

Once `search()` is registered, `GloodProvider` forwards Shopify standard events
(`product_viewed`, `search_submitted`, `product_added_to_cart`, …) to the search backend
with no extra code. Narrow them if you want:

```tsx theme={null}
createGlood({ apiKey, myShopifyDomain })
  .use(search({ events: ['search_submitted', 'product_added_to_cart'] }));
```

## 8. Glood Search Events

On top of the standard events, the Glood search app emits custom attribution events. The
search page above already sends the click and filter ones via the track helpers:

| Event                          | Fired when                          | How                                           |
| ------------------------------ | ----------------------------------- | --------------------------------------------- |
| `glood:search_result_rendered` | Results render / enter the viewport | `trackResultView(track)`                      |
| `glood:search_result_clicked`  | A result is clicked                 | `trackResultClick(track)` — see §4            |
| `glood:search_filter_appeared` | The filter UI is rendered           | `sendSearchEvent(options, event)` (no helper) |
| `glood:search_filter_updated`  | A facet is applied / changed        | `trackFilter(track)` — see §4                 |

Each event carries a specific `custom_data.track` payload on the wire — see the
[event-wise payload structure](/api-reference/search/headless/events#event-payload-structure)
in the Search Events API reference for the exact shape of every standard and `glood:` event.

## What You'll See in the Console (debug: true)

```
[Glood Debug] Registered search app
[Glood Debug] App search subscribes to events: ['page_viewed', 'product_viewed', 'search_submitted', ...]
[Glood Debug] Fetching search: https://search.glood.ai/api/storefront/v1/headless/search {query: 'shoe', ...}
[Glood Debug] Successfully sent event to https://search.glood.ai/api/storefront/event
```

## See Also

* [Search & Discovery Guide](/for-developers/glood-hydrogen-sdk/search)
* [Search Events API — event payload structure](/api-reference/search/headless/events#event-payload-structure)
* [Search API Reference](/for-developers/glood-hydrogen-sdk/api-reference/search-api)
* [Content Security Policy](/for-developers/glood-hydrogen-sdk/content-security-policy)
