> ## 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'] }));
```

## 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 API Reference](/for-developers/glood-hydrogen-sdk/api-reference/search-api)
* [Content Security Policy](/for-developers/glood-hydrogen-sdk/content-security-policy)
