@shopify/cli
Version:
A CLI tool to build for the Shopify platform
336 lines (290 loc) • 8.05 kB
Markdown
# Hydrogen Search
Our skeleton template ships with a `/search` route and a set of components to easily
implement a traditional search flow.
This integration uses the storefront API (SFAPI) [search](https://shopify.dev/docs/api/storefront/latest/queries/search)
endpoint to retrieve search results based on a search term.
## Components Architecture

## Components
| File | Description |
| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| [`app/components/SearchForm.tsx`](app/components/SearchForm.tsx) | A fully customizable form component configured to make (server-side) form `GET` requests to the `/search` route. |
| [`app/components/SearchResults.tsx`](app/components/SearchResults.tsx) | A fully customizable search results wrapper, that provides compound components to render `articles`, `pages` and `products` |
## Instructions
### 1. Create the search route
Create a new file at `/routes/search.tsx`
### 3. Add `search` query and fetcher
The search fetcher parses the `q` parameter and performs the search SFAPI request.
```ts
/**
* Regular search query and fragments
* (adjust as needed)
*/
const SEARCH_PRODUCT_FRAGMENT = `#graphql
fragment SearchProduct on Product {
__typename
handle
id
publishedAt
title
trackingParameters
vendor
selectedOrFirstAvailableVariant(
selectedOptions: []
ignoreUnknownOptions: true
caseInsensitiveMatch: true
) {
id
image {
url
altText
width
height
}
price {
amount
currencyCode
}
compareAtPrice {
amount
currencyCode
}
selectedOptions {
name
value
}
product {
handle
title
}
}
}
` as const;
const SEARCH_PAGE_FRAGMENT = `#graphql
fragment SearchPage on Page {
__typename
handle
id
title
trackingParameters
}
` as const;
const SEARCH_ARTICLE_FRAGMENT = `#graphql
fragment SearchArticle on Article {
__typename
handle
id
title
trackingParameters
}
` as const;
const PAGE_INFO_FRAGMENT = `#graphql
fragment PageInfoFragment on PageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
` as const;
// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/search
export const SEARCH_QUERY = `#graphql
query Search(
$country: CountryCode
$endCursor: String
$first: Int
$language: LanguageCode
$last: Int
$term: String!
$startCursor: String
) @inContext(country: $country, language: $language) {
articles: search(
query: $term,
types: [ARTICLE],
first: $first,
) {
nodes {
...on Article {
...SearchArticle
}
}
}
pages: search(
query: $term,
types: [PAGE],
first: $first,
) {
nodes {
...on Page {
...SearchPage
}
}
}
products: search(
after: $endCursor,
before: $startCursor,
first: $first,
last: $last,
query: $term,
sortKey: RELEVANCE,
types: [PRODUCT],
unavailableProducts: HIDE,
) {
nodes {
...on Product {
...SearchProduct
}
}
pageInfo {
...PageInfoFragment
}
}
}
${SEARCH_PRODUCT_FRAGMENT}
${SEARCH_PAGE_FRAGMENT}
${SEARCH_ARTICLE_FRAGMENT}
${PAGE_INFO_FRAGMENT}
` as const;
/**
* Regular search fetcher
*/
async function search({
request,
context,
}: Pick<LoaderFunctionArgs, 'request' | 'context'>) {
const {storefront} = context;
const url = new URL(request.url);
const searchParams = new URLSearchParams(url.search);
const variables = getPaginationVariables(request, {pageBy: 8});
const term = String(searchParams.get('q') || '');
// Search articles, pages, and products for the `q` term
const {errors, ...items} = await storefront.query(SEARCH_QUERY, {
variables: {...variables, term},
});
if (!items) {
throw new Error('No search data returned from Shopify API');
}
if (errors) {
throw new Error(errors[0].message);
}
const total = Object.values(items).reduce((acc, {nodes}) => {
return acc + nodes.length;
}, 0);
return {term, result: {total, items}};
}
```
### 3. Add a `loader` export to the route
This loader receives and processes `GET` requests from the `<SearchForm />` component.
A `q` URL parameter will be used as the search term and appended automatically by
the form if present in it's children prop
```ts
/**
* Handles regular search GET requests
* requested by the SearchForm component and /search route visits
*/
export async function loader({request, context}: LoaderFunctionArgs) {
const url = new URL(request.url);
const isRegular = !url.searchParams.has('predictive');
if (!isRegular) {
return {};
}
const searchPromise = regularSearch({request, context});
searchPromise.catch((error: Error) => {
console.error(error);
return {term: '', result: null, error: error.message};
});
return await searchPromise;
}
```
### 4. Render the search form and results
Finally, create a default export to render both the search form and the search results
```ts
import {SearchForm} from '~/components/SearchForm';
import {SearchResults} from '~/components/SearchResults';
/**
* Renders the /search route
*/
export default function SearchPage() {
const {term, result} = useLoaderData<typeof loader>();
return (
<div className="search">
<h1>Search</h1>
<SearchForm>
{({inputRef}) => (
<>
<input
defaultValue={term}
name="q"
placeholder="Search…"
ref={inputRef}
type="search"
/>
<button type="submit">Search</button>
</>
)}
</SearchForm>
{!term || !result?.total ? (
<SearchResults.Empty />
) : (
<SearchResults result={result} term={term}>
{({articles, pages, products, term}) => (
<div>
<SearchResults.Products products={products} term={term} />
<SearchResults.Pages pages={pages} term={term} />
<SearchResults.Articles articles={articles} term={term} />
</div>
)}
</SearchResults>
)}
</div>
);
}
```
## Additional Notes
### How to use a different URL search parameter?
- Modify the `name` attribute in the forms input element. e.g
```ts
<input name="query" />`.
```
- Modify the search fetcher term variable to parse the new name. e.g
```ts
const term = String(searchParams.get('query') || '');
```
### How to customize the way the results look?
Simply go to `/app/components/SearchResults.txx` and look for the compound component you
want to modify.
For example, let's render articles in a horizontal flex container
```diff
SearchResults.Pages = function({
pages,
term,
}: {
pages: SearchItems['pages'];
term: string;
}) {
if (!pages?.nodes.length) {
return null;
}
return (
<div className="search-result">
<h2>Pages</h2>
+ <div className="flex">
{pages?.nodes?.map((page) => {
const pageUrl = urlWithTrackingParams({
baseUrl: `/pages/${page.handle}`,
trackingParams: page.trackingParameters,
term,
});
return (
<div className="search-results-item" key={page.id}>
<Link prefetch="intent" to={pageUrl}>
{page.title}
</Link>
</div>
);
})}
</div>
</div>
);
};
```