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):// 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());
root.tsx (inside Shopify’s Analytics.Provider):
// 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 inentry.server:
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:// 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:// 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:// 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:// 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>
);
}
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).7. Standard Events Are Automatic
Oncesearch() 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:
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