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/StateSourceLiquid Field
initialData.termsLiquid JSONsearch.terms
initialData.resultsCountLiquid JSONsearch.results_count
initialData.resultsLiquid JSONsearch.results (array)
initialData.paginationLiquid JSONpaginate.* fields
initialData.typesLiquid JSONsearch.types
initialData.sortByLiquid JSONsearch.sort_by
queryURL param?q=...
typeURL param?type=product|article|page
sortByURL param?sort_by=...
pageURL param?page=...
isLoadingLocal 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

src/components/search/SearchResultsPage/SearchResultsPage.tsx
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>
);
}
src/components/search/SearchResultsPage/SearchResultsPage.module.css
.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

src/components/search/SearchResultsPage/SearchFilters.tsx
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>
);
}
src/components/search/SearchResultsPage/SearchFilters.module.css
.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

src/hooks/useSearchParams.ts
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

src/components/search/SearchResultsPage/SearchPagination.tsx
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;
}
src/components/search/SearchResultsPage/SearchPagination.module.css
.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

src/entries/search.tsx
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

  1. Initial data from Liquid: Server-render first page for SEO
  2. URL-based state: Store filters, sort, and page in URL
  3. Client-side updates: Fetch new results without full page reload
  4. Pagination: Show page numbers with ellipsis for large result sets
  5. Filters: Type and sort options with immediate updates
  6. No results: Helpful suggestions when search returns empty
  7. 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...