Search Components Intermediate 10 min read
Search Results Page Integration
Build a full search results page with React. Handle URL parameters, pagination, filtering, and integrate with Shopify search.
While predictive search is great for quick lookups, customers need a full search results page to browse and filter all matches. Let’s build a search page that complements our modal.
Theme Integration
The search results page is a full template, separate from the modal:
templates/search.liquid → sections/search-results.liquid└── <div id="search-results-root"> └── SearchResultsPage (React) ← You are here ├── SearchHeader (query + count) ├── FilterSidebar (type filters) └── ProductGrid (reused from collection){% comment %} sections/search-results.liquid {% endcomment %}<div id="search-results-root" data-search-query="{{ search.terms | escape }}"></div>
<script type="application/json" id="search-data"> { "query": {{ search.terms | json }}, "resultsCount": {{ search.results_count }}, "products": [{% for item in search.results %}...{% endfor %}] }</script>Data Source
| Prop/State | Source | Liquid Field |
|---|---|---|
initialData.terms | Liquid JSON | search.terms |
initialData.resultsCount | Liquid JSON | search.results_count |
initialData.results | Liquid JSON | search.results (array) |
initialData.pagination | Liquid JSON | paginate.* fields |
initialData.types | Liquid JSON | search.types |
initialData.sortBy | Liquid JSON | search.sort_by |
query | URL param | ?q=... |
type | URL param | ?type=product|article|page |
sortBy | URL param | ?sort_by=... |
page | URL param | ?page=... |
isLoading | Local state | - |
Note: Initial search results come from Liquid (server-rendered). Subsequent filter/sort/page changes fetch via AJAX.
Search Page Architecture
┌─────────────────────────────────────────────────────────────────────────────┐│ SEARCH RESULTS PAGE ││ ││ ┌───────────────────────────────────────────────────────────────────────┐ ││ │ 🔍 "summer dress" [Search] │ ││ └───────────────────────────────────────────────────────────────────────┘ ││ ││ Showing 24 results for "summer dress" ││ ││ ┌─────────────────┐ ┌───────────────────────────────────────────────────┐ ││ │ FILTERS │ │ PRODUCT GRID │ ││ │ │ │ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ ││ │ Type │ │ │ │ │ │ │ │ │ │ │ ││ │ ○ Products │ │ │ │ │ │ │ │ │ │ │ ││ │ ○ Collections │ │ └─────┘ └─────┘ └─────┘ └─────┘ │ ││ │ ○ Pages │ │ │ ││ │ │ │ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ ││ │ Sort by │ │ │ │ │ │ │ │ │ │ │ ││ │ [Relevance ▾] │ │ │ │ │ │ │ │ │ │ │ ││ │ │ │ └─────┘ └─────┘ └─────┘ └─────┘ │ ││ └─────────────────┘ │ │ ││ │ [ 1 ] 2 3 4 ... 10 → │ ││ └───────────────────────────────────────────────────┘ │└─────────────────────────────────────────────────────────────────────────────┘Liquid Data Serialization
{% comment %} sections/search-results.liquid Serialize search results for React to hydrate.{% endcomment %}
{% comment %} Only render if there's a search query {% endcomment %}{% if search.performed %} <div id="search-results-root" data-query="{{ search.terms | escape }}" data-type="{{ search.types | join: ',' }}" data-sort="{{ search.sort_by }}" ></div>
{% comment %} Initial search results as JSON {% endcomment %} <script type="application/json" id="search-results-data"> { "terms": {{ search.terms | json }}, "performed": {{ search.performed }}, "resultsCount": {{ search.results_count }}, "currentPage": {{ paginate.current_page }}, "totalPages": {{ paginate.pages }}, "types": {{ search.types | json }}, "sortBy": {{ search.sort_by | json }}, "results": [ {%- for result in search.results -%} { "objectType": {{ result.object_type | json }}, "id": {{ result.id }}, "title": {{ result.title | json }}, "url": {{ result.url | json }} {%- if result.object_type == 'product' -%} ,"handle": {{ result.handle | json }} ,"price": {{ result.price }} ,"compareAtPrice": {{ result.compare_at_price | default: 'null' }} ,"available": {{ result.available }} ,"vendor": {{ result.vendor | json }} ,"featuredImage": {% if result.featured_image %} { "url": {{ result.featured_image | image_url: width: 400 | json }}, "alt": {{ result.featured_image.alt | json }} } {% else %}null{% endif %} {%- elsif result.object_type == 'article' -%} ,"excerpt": {{ result.excerpt | strip_html | truncate: 150 | json }} ,"publishedAt": {{ result.published_at | date: '%Y-%m-%d' | json }} {%- elsif result.object_type == 'page' -%} ,"content": {{ result.content | strip_html | truncate: 150 | json }} {%- endif -%} }{% unless forloop.last %},{% endunless %} {%- endfor -%} ], "pagination": { "current": {{ paginate.current_page }}, "total": {{ paginate.pages }}, "prev": {% if paginate.previous %}{{ paginate.previous.url | json }}{% else %}null{% endif %}, "next": {% if paginate.next %}{{ paginate.next.url | json }}{% else %}null{% endif %} } } </script>{% else %} {% comment %} No search performed - show search form {% endcomment %} <div id="search-form-root"></div>{% endif %}SearchResultsPage Component
import { useState, useEffect } from 'react';import { useSearchParams } from '@/hooks/useSearchParams';import { SearchForm } from './SearchForm';import { SearchFilters } from './SearchFilters';import { SearchResultsGrid } from './SearchResultsGrid';import { SearchPagination } from './SearchPagination';import type { SearchPageData, SearchResult } from '@/types/search';import styles from './SearchResultsPage.module.css';
interface SearchResultsPageProps { initialData: SearchPageData;}
/** * SearchResultsPage is the full-page search results view. * Handles filtering, sorting, and pagination. */export function SearchResultsPage({ initialData }: SearchResultsPageProps) { // Get URL params for filters. const { query, type, sortBy, page, updateParams } = useSearchParams();
// State for results (initially from Liquid, then from API). const [results, setResults] = useState<SearchResult[]>(initialData.results); const [resultsCount, setResultsCount] = useState(initialData.resultsCount); const [isLoading, setIsLoading] = useState(false);
// Pagination state. const [pagination, setPagination] = useState(initialData.pagination);
// Fetch new results when filters change. useEffect(() => { // Skip if this is the initial render with matching data. if ( query === initialData.terms && type === (initialData.types?.[0] || 'product') && sortBy === initialData.sortBy && page === initialData.pagination.current ) { return; }
const fetchResults = async () => { setIsLoading(true);
try { const params = new URLSearchParams({ q: query, type: type, sort_by: sortBy, page: page.toString(), view: 'json', // Request JSON response. });
const response = await fetch(`/search?${params.toString()}`); const data = await response.json();
setResults(data.results); setResultsCount(data.resultsCount); setPagination(data.pagination); } catch (error) { console.error('Failed to fetch search results:', error); } finally { setIsLoading(false); } };
fetchResults(); }, [query, type, sortBy, page, initialData]);
// Handle search form submission. const handleSearch = (newQuery: string) => { updateParams({ q: newQuery, page: '1' }); };
// Handle filter changes. const handleTypeChange = (newType: string) => { updateParams({ type: newType, page: '1' }); };
const handleSortChange = (newSort: string) => { updateParams({ sort_by: newSort, page: '1' }); };
// Handle pagination. const handlePageChange = (newPage: number) => { updateParams({ page: newPage.toString() }); // Scroll to top of results. window.scrollTo({ top: 0, behavior: 'smooth' }); };
return ( <div className={styles.page}> {/* Search form */} <SearchForm initialQuery={query} onSearch={handleSearch} />
{/* Results header */} <div className={styles.header}> <h1 className={styles.title}> {resultsCount > 0 ? ( <> {resultsCount} results for "<span>{query}</span>" </> ) : ( <>No results for "{query}"</> )} </h1> </div>
{resultsCount > 0 ? ( <div className={styles.layout}> {/* Sidebar filters */} <aside className={styles.sidebar}> <SearchFilters currentType={type} currentSort={sortBy} onTypeChange={handleTypeChange} onSortChange={handleSortChange} /> </aside>
{/* Results grid */} <main className={styles.main}> <SearchResultsGrid results={results} type={type} isLoading={isLoading} />
{/* Pagination */} {pagination.total > 1 && ( <SearchPagination current={pagination.current} total={pagination.total} onChange={handlePageChange} /> )} </main> </div> ) : ( <NoResults query={query} /> )} </div> );}
/** * NoResults component shown when search returns no matches. */function NoResults({ query }: { query: string }) { return ( <div className={styles.noResults}> <h2>No results found</h2> <p>We couldn't find anything matching "{query}".</p> <ul> <li>Check your spelling</li> <li>Try using fewer or different keywords</li> <li>Try a more general search term</li> </ul> <a href="/collections/all" className={styles.browseLink}> Browse all products </a> </div> );}.page { max-width: var(--container-width, 1200px); margin: 0 auto; padding: 2rem 1.5rem;}
.header { margin-bottom: 2rem;}
.title { font-size: 1.5rem; font-weight: 600; margin: 0;}
.title span { color: var(--color-primary);}
.layout { display: grid; grid-template-columns: 250px 1fr; gap: 2rem; align-items: start;}
.sidebar { position: sticky; top: 2rem;}
.main { min-width: 0;}
/* No results state */.noResults { text-align: center; padding: 4rem 2rem; max-width: 500px; margin: 0 auto;}
.noResults h2 { margin: 0 0 0.5rem; font-size: 1.5rem;}
.noResults p { color: var(--color-text-muted); margin: 0 0 1.5rem;}
.noResults ul { text-align: left; margin: 0 0 2rem; padding-left: 1.5rem; color: var(--color-text-muted);}
.browseLink { display: inline-block; padding: 0.75rem 1.5rem; background-color: var(--color-primary); color: var(--color-background); text-decoration: none; border-radius: var(--radius-sm); font-weight: 500; transition: background-color 0.15s ease;}
.browseLink:hover { background-color: var(--color-primary-dark);}
/* Mobile layout */@media (max-width: 768px) { .layout { grid-template-columns: 1fr; }
.sidebar { position: static; }}SearchFilters Component
import styles from './SearchFilters.module.css';
interface SearchFiltersProps { currentType: string; currentSort: string; onTypeChange: (type: string) => void; onSortChange: (sort: string) => void;}
const SEARCH_TYPES = [ { value: 'product', label: 'Products' }, { value: 'article', label: 'Articles' }, { value: 'page', label: 'Pages' },];
const SORT_OPTIONS = [ { value: 'relevance', label: 'Relevance' }, { value: 'price-ascending', label: 'Price: Low to High' }, { value: 'price-descending', label: 'Price: High to Low' }, { value: 'created-descending', label: 'Newest First' }, { value: 'title-ascending', label: 'A-Z' },];
/** * SearchFilters provides type and sort filters for search results. */export function SearchFilters({ currentType, currentSort, onTypeChange, onSortChange,}: SearchFiltersProps) { return ( <div className={styles.filters}> {/* Type filter */} <fieldset className={styles.group}> <legend className={styles.legend}>Type</legend> <div className={styles.options}> {SEARCH_TYPES.map((type) => ( <label key={type.value} className={styles.option}> <input type="radio" name="type" value={type.value} checked={currentType === type.value} onChange={() => onTypeChange(type.value)} className={styles.radio} /> <span className={styles.label}>{type.label}</span> </label> ))} </div> </fieldset>
{/* Sort dropdown */} <div className={styles.group}> <label htmlFor="sort" className={styles.legend}> Sort by </label> <select id="sort" value={currentSort} onChange={(e) => onSortChange(e.target.value)} className={styles.select} > {SORT_OPTIONS.map((option) => ( <option key={option.value} value={option.value}> {option.label} </option> ))} </select> </div> </div> );}.filters { display: flex; flex-direction: column; gap: 1.5rem; padding: 1.5rem; background-color: var(--color-surface); border-radius: var(--radius-md);}
.group { border: none; margin: 0; padding: 0;}
.legend { display: block; margin-bottom: 0.75rem; font-size: 0.875rem; font-weight: 600; color: var(--color-text);}
.options { display: flex; flex-direction: column; gap: 0.5rem;}
.option { display: flex; align-items: center; gap: 0.5rem; cursor: pointer;}
.radio { width: 1rem; height: 1rem; accent-color: var(--color-primary);}
.label { font-size: 0.9375rem;}
.select { width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--color-border); border-radius: var(--radius-sm); background-color: var(--color-background); font-size: 0.9375rem; cursor: pointer;}
.select:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb), 0.1);}useSearchParams Hook
import { useMemo, useCallback } from 'react';
/** * useSearchParams provides access to URL search parameters * with type-safe getters and update functions. */export function useSearchParams() { // Parse current URL params. const params = useMemo(() => { if (typeof window === 'undefined') { return new URLSearchParams(); } return new URLSearchParams(window.location.search); }, []);
// Get individual params with defaults. const query = params.get('q') || ''; const type = params.get('type') || 'product'; const sortBy = params.get('sort_by') || 'relevance'; const page = parseInt(params.get('page') || '1', 10);
// Update URL params and navigate. const updateParams = useCallback( (updates: Record<string, string>) => { const newParams = new URLSearchParams(window.location.search);
Object.entries(updates).forEach(([key, value]) => { if (value) { newParams.set(key, value); } else { newParams.delete(key); } });
// Update URL without full page reload. const newUrl = `${window.location.pathname}?${newParams.toString()}`; window.history.pushState({}, '', newUrl);
// Trigger a custom event so components can react. window.dispatchEvent(new CustomEvent('searchparamschange')); }, [] );
return { query, type, sortBy, page, updateParams, };}SearchPagination Component
import styles from './SearchPagination.module.css';
interface SearchPaginationProps { current: number; total: number; onChange: (page: number) => void;}
/** * SearchPagination displays page navigation for search results. * Shows first, last, current, and nearby page numbers. */export function SearchPagination({ current, total, onChange }: SearchPaginationProps) { // Generate page numbers to display. const pages = getPageNumbers(current, total);
return ( <nav className={styles.pagination} role="navigation" aria-label="Search results pagination" > {/* Previous button */} <button type="button" className={styles.navButton} onClick={() => onChange(current - 1)} disabled={current === 1} aria-label="Previous page" > ← </button>
{/* Page numbers */} <div className={styles.pages}> {pages.map((page, index) => { if (page === '...') { return ( <span key={`ellipsis-${index}`} className={styles.ellipsis}> ... </span> ); }
const pageNum = page as number; const isCurrent = pageNum === current;
return ( <button key={pageNum} type="button" className={`${styles.pageButton} ${isCurrent ? styles.current : ''}`} onClick={() => onChange(pageNum)} aria-label={`Page ${pageNum}`} aria-current={isCurrent ? 'page' : undefined} > {pageNum} </button> ); })} </div>
{/* Next button */} <button type="button" className={styles.navButton} onClick={() => onChange(current + 1)} disabled={current === total} aria-label="Next page" > → </button> </nav> );}
/** * Generate array of page numbers with ellipsis for gaps. */function getPageNumbers(current: number, total: number): (number | '...')[] { const pages: (number | '...')[] = [];
// Always show first page. pages.push(1);
// Add ellipsis if there's a gap after first page. if (current > 3) { pages.push('...'); }
// Add pages around current. for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) { if (!pages.includes(i)) { pages.push(i); } }
// Add ellipsis if there's a gap before last page. if (current < total - 2) { pages.push('...'); }
// Always show last page (if more than 1 page). if (total > 1 && !pages.includes(total)) { pages.push(total); }
return pages;}.pagination { display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-top: 2rem; padding-top: 2rem; border-top: 1px solid var(--color-border);}
.pages { display: flex; align-items: center; gap: 0.25rem;}
.navButton,.pageButton { display: flex; align-items: center; justify-content: center; min-width: 2.5rem; height: 2.5rem; padding: 0 0.5rem; border: 1px solid var(--color-border); background-color: var(--color-background); color: var(--color-text); font-size: 0.875rem; cursor: pointer; border-radius: var(--radius-sm); transition: background-color 0.15s ease, border-color 0.15s ease;}
.navButton:hover:not(:disabled),.pageButton:hover:not(.current) { background-color: var(--color-surface); border-color: var(--color-text-muted);}
.navButton:disabled { opacity: 0.4; cursor: not-allowed;}
.pageButton.current { background-color: var(--color-primary); border-color: var(--color-primary); color: var(--color-background);}
.ellipsis { padding: 0 0.25rem; color: var(--color-text-muted);}Mounting the Search Page
import { createRoot } from 'react-dom/client';import { SearchResultsPage } from '@/components/search/SearchResultsPage';import type { SearchPageData } from '@/types/search';
/** * Mount the search results page. * Reads initial data from Liquid-generated JSON. */export function mountSearchResultsPage() { const container = document.getElementById('search-results-root'); if (!container) return;
// Read initial data from JSON script. const dataScript = document.getElementById('search-results-data'); if (!dataScript) return;
try { const initialData: SearchPageData = JSON.parse(dataScript.textContent || ''); createRoot(container).render(<SearchResultsPage initialData={initialData} />); } catch (error) { console.error('Failed to mount search results page:', error); }}Key Takeaways
- Initial data from Liquid: Server-render first page for SEO
- URL-based state: Store filters, sort, and page in URL
- Client-side updates: Fetch new results without full page reload
- Pagination: Show page numbers with ellipsis for large result sets
- Filters: Type and sort options with immediate updates
- No results: Helpful suggestions when search returns empty
- Accessibility: Proper ARIA labels, keyboard navigation
This completes our search functionality—from predictive search modal to full results page!
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...