๐งช getSearchResult()
Examples
Real-world examples showing how to implement search functionality in your Shopify headless storefront.
๐ Basic Search Pageโ
app/search/page.tsx
import { getSearchResult } from "lib/nextshopkit/client";
interface SearchPageProps {
searchParams: {
q?: string;
page?: string;
};
}
export default async function SearchPage({ searchParams }: SearchPageProps) {
const query = searchParams.q || "";
const page = parseInt(searchParams.page || "1");
const limit = 12;
if (!query) {
return (
<div className="container mx-auto px-4 py-8">
<h1>Search Products</h1>
<p>Enter a search term to find products.</p>
</div>
);
}
const { data, error } = await getSearchResult({
query,
limit,
types: ["PRODUCT"],
unavailableProducts: "HIDE",
});
if (error) {
return (
<div className="container mx-auto px-4 py-8">
<h1>Search Error</h1>
<p>Failed to search: {error}</p>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<h1>Search Results for "{data.searchTerm}"</h1>
<p>Found {data.totalCount} products</p>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-6 mt-8">
{data.products.map((product) => (
<div key={product.id} className="border rounded-lg p-4">
{product.featuredImage && (
<img
src={product.featuredImage.url}
alt={product.title}
className="w-full h-48 object-cover rounded"
/>
)}
<h3 className="font-semibold mt-2">{product.title}</h3>
<p className="text-gray-600">
{product.price.amount} {product.price.currencyCode}
</p>
</div>
))}
</div>
</div>
);
}
๐๏ธ Advanced Search with Filtersโ
components/AdvancedSearch.tsx
"use client";
import { useState, useEffect } from "react";
interface SearchFilters {
query: string;
minPrice?: number;
maxPrice?: number;
tags: string[];
sortKey: "RELEVANCE" | "PRICE" | "CREATED_AT";
reverse: boolean;
}
export default function AdvancedSearch() {
const [filters, setFilters] = useState<SearchFilters>({
query: "",
tags: [],
sortKey: "RELEVANCE",
reverse: false,
});
const [results, setResults] = useState(null);
const [loading, setLoading] = useState(false);
const searchProducts = async () => {
if (!filters.query.trim()) return;
setLoading(true);
try {
const productFilters = [];
// Add price filter
if (filters.minPrice || filters.maxPrice) {
productFilters.push({
price: {
...(filters.minPrice && { min: filters.minPrice }),
...(filters.maxPrice && { max: filters.maxPrice }),
},
});
}
// Add tag filters
filters.tags.forEach((tag) => {
productFilters.push({ productTag: tag });
});
// Always show only available products
productFilters.push({ available: true });
const response = await fetch("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: filters.query,
productFilters,
sortKey: filters.sortKey,
reverse: filters.reverse,
limit: 20,
}),
});
const data = await response.json();
setResults(data);
} catch (error) {
console.error("Search failed:", error);
} finally {
setLoading(false);
}
};
return (
<div className="max-w-6xl mx-auto p-6">
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-2xl font-bold mb-4">Advanced Product Search</h2>
{/* Search Input */}
<div className="mb-4">
<input
type="text"
placeholder="Search products..."
value={filters.query}
onChange={(e) => setFilters({ ...filters, query: e.target.value })}
className="w-full p-3 border rounded-lg"
/>
</div>
{/* Price Range */}
<div className="grid grid-cols-2 gap-4 mb-4">
<input
type="number"
placeholder="Min Price"
value={filters.minPrice || ""}
onChange={(e) =>
setFilters({
...filters,
minPrice: e.target.value ? Number(e.target.value) : undefined,
})
}
className="p-2 border rounded"
/>
<input
type="number"
placeholder="Max Price"
value={filters.maxPrice || ""}
onChange={(e) =>
setFilters({
...filters,
maxPrice: e.target.value ? Number(e.target.value) : undefined,
})
}
className="p-2 border rounded"
/>
</div>
{/* Sort Options */}
<div className="flex gap-4 mb-4">
<select
value={filters.sortKey}
onChange={(e) =>
setFilters({
...filters,
sortKey: e.target.value as any,
})
}
className="p-2 border rounded"
>
<option value="RELEVANCE">Relevance</option>
<option value="PRICE">Price</option>
<option value="CREATED_AT">Newest</option>
</select>
<label className="flex items-center">
<input
type="checkbox"
checked={filters.reverse}
onChange={(e) =>
setFilters({ ...filters, reverse: e.target.checked })
}
className="mr-2"
/>
Reverse Order
</label>
</div>
<button
onClick={searchProducts}
disabled={loading || !filters.query.trim()}
className="bg-blue-600 text-white px-6 py-2 rounded-lg disabled:opacity-50"
>
{loading ? "Searching..." : "Search"}
</button>
</div>
{/* Results */}
{results && (
<div>
<h3 className="text-xl font-semibold mb-4">
Found {results.totalCount} products for "{results.searchTerm}"
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-6">
{results.products.map((product) => (
<div key={product.id} className="border rounded-lg p-4">
{product.featuredImage && (
<img
src={product.featuredImage.url}
alt={product.title}
className="w-full h-48 object-cover rounded"
/>
)}
<h4 className="font-semibold mt-2">{product.title}</h4>
<p className="text-gray-600">
{product.price.amount} {product.price.currencyCode}
</p>
</div>
))}
</div>
</div>
)}
</div>
);
}
๐ Paginated Search Resultsโ
components/PaginatedSearch.tsx
"use client";
import { useState } from "react";
export default function PaginatedSearch() {
const [query, setQuery] = useState("");
const [results, setResults] = useState(null);
const [loading, setLoading] = useState(false);
const [cursor, setCursor] = useState(null);
const searchProducts = async (searchCursor = null, reset = false) => {
if (!query.trim()) return;
setLoading(true);
try {
const response = await fetch("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query,
limit: 12,
cursor: searchCursor,
types: ["PRODUCT"],
unavailableProducts: "HIDE",
}),
});
const data = await response.json();
if (reset) {
setResults(data);
} else {
// Append to existing results for "Load More"
setResults((prev) => ({
...data,
products: [...(prev?.products || []), ...data.products],
}));
}
setCursor(data.pageInfo?.endCursor || null);
} catch (error) {
console.error("Search failed:", error);
} finally {
setLoading(false);
}
};
const handleSearch = () => {
searchProducts(null, true);
};
const loadMore = () => {
if (cursor && results?.pageInfo?.hasNextPage) {
searchProducts(cursor, false);
}
};
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mb-6">
<div className="flex gap-2">
<input
type="text"
placeholder="Search products..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && handleSearch()}
className="flex-1 p-3 border rounded-lg"
/>
<button
onClick={handleSearch}
disabled={loading || !query.trim()}
className="bg-blue-600 text-white px-6 py-2 rounded-lg"
>
Search
</button>
</div>
</div>
{results && (
<div>
<p className="mb-4 text-gray-600">
Showing {results.products.length} of {results.totalCount} results
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
{results.products.map((product) => (
<div key={product.id} className="border rounded-lg p-4">
{product.featuredImage && (
<img
src={product.featuredImage.url}
alt={product.title}
className="w-full h-48 object-cover rounded"
/>
)}
<h3 className="font-semibold mt-2">{product.title}</h3>
<p className="text-gray-600">
{product.price.amount} {product.price.currencyCode}
</p>
</div>
))}
</div>
{results.pageInfo?.hasNextPage && (
<div className="text-center">
<button
onClick={loadMore}
disabled={loading}
className="bg-gray-600 text-white px-6 py-2 rounded-lg"
>
{loading ? "Loading..." : "Load More"}
</button>
</div>
)}
</div>
)}
</div>
);
}
๐ ๏ธ API Route Implementationโ
app/api/search/route.ts
import { getSearchResult } from "lib/nextshopkit/client";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const {
query,
limit = 12,
cursor,
productFilters = [],
sortKey = "RELEVANCE",
reverse = false,
types = ["PRODUCT"],
unavailableProducts = "HIDE",
} = body;
if (!query || query.trim() === "") {
return NextResponse.json(
{ error: "Search query is required" },
{ status: 400 }
);
}
const result = await getSearchResult({
query: query.trim(),
limit,
cursor,
productFilters,
sortKey,
reverse,
types,
unavailableProducts,
productMetafields: [
{ field: "custom.brand", type: "single_line_text" },
{ field: "custom.warranty_years", type: "number_integer" },
],
options: {
camelizeKeys: true,
resolveFiles: true,
renderRichTextAsHtml: true,
},
});
return NextResponse.json(result);
} catch (error) {
console.error("Search API error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
๐ฏ Autocomplete Searchโ
components/SearchAutocomplete.tsx
"use client";
import { useState, useEffect, useRef } from "react";
import { debounce } from "lodash";
export default function SearchAutocomplete() {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState([]);
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const inputRef = useRef(null);
const searchSuggestions = debounce(async (searchQuery) => {
if (!searchQuery.trim() || searchQuery.length < 2) {
setSuggestions([]);
setIsOpen(false);
return;
}
setLoading(true);
try {
const response = await fetch("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: searchQuery,
limit: 5,
types: ["PRODUCT"],
unavailableProducts: "HIDE",
}),
});
const data = await response.json();
setSuggestions(data.products || []);
setIsOpen(true);
} catch (error) {
console.error("Autocomplete search failed:", error);
} finally {
setLoading(false);
}
}, 300);
useEffect(() => {
searchSuggestions(query);
}, [query]);
const handleSelect = (product) => {
setQuery(product.title);
setIsOpen(false);
// Navigate to product page
window.location.href = `/products/${product.handle}`;
};
return (
<div className="relative w-full max-w-md">
<input
ref={inputRef}
type="text"
placeholder="Search products..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => suggestions.length > 0 && setIsOpen(true)}
onBlur={() => setTimeout(() => setIsOpen(false), 200)}
className="w-full p-3 border rounded-lg pr-10"
/>
{loading && (
<div className="absolute right-3 top-3">
<div className="animate-spin h-5 w-5 border-2 border-blue-600 border-t-transparent rounded-full"></div>
</div>
)}
{isOpen && suggestions.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border rounded-lg shadow-lg max-h-80 overflow-y-auto">
{suggestions.map((product) => (
<div
key={product.id}
onClick={() => handleSelect(product)}
className="flex items-center p-3 hover:bg-gray-50 cursor-pointer border-b last:border-b-0"
>
{product.featuredImage && (
<img
src={product.featuredImage.url}
alt={product.title}
className="w-12 h-12 object-cover rounded mr-3"
/>
)}
<div className="flex-1">
<h4 className="font-medium text-sm">{product.title}</h4>
<p className="text-gray-600 text-xs">
{product.price.amount} {product.price.currencyCode}
</p>
</div>
</div>
))}
</div>
)}
</div>
);
}
๐๏ธ Practical Filter Parsing from URL Parametersโ
Real-world applications often need to parse filters from URL search parameters. Here's a robust utility function:
lib/utils/parseFilters.ts
export const parseFilters = (params: {
[key: string]: string | string[] | undefined;
}) => {
const filters: (
| { available?: boolean }
| { variantOption?: { name: string; value: string } }
| { productMetafield: { namespace: string; key: string; value: string } }
| { productTag: string }
| { productType: string }
| { collection?: string }
| { price: { min?: number; max?: number } }
)[] = [];
Object.entries(params).forEach(([key, value]) => {
if (key.startsWith("filter_") && value) {
const filterKey = key.replace("filter_", "");
const values = Array.isArray(value) ? value : [value];
values.forEach((val) => {
if (!val) return;
switch (filterKey) {
case "available":
filters.push({ available: val === "true" });
break;
case "price":
const [min, max] = val.split("-");
filters.push({
price: {
min: min ? parseFloat(min) : undefined,
max: max ? parseFloat(max) : undefined,
},
});
break;
case "productType":
filters.push({ productType: val });
break;
case "productTag":
filters.push({ productTag: val });
break;
case "collection":
filters.push({ collection: val });
break;
default:
// Check if this is a product metafield (pattern: namespace.key)
if (filterKey.includes(".")) {
const [namespace, key] = filterKey.split(".");
filters.push({
productMetafield: {
namespace,
key,
value: val,
},
});
} else {
// Handle variant options (size, color, etc.)
filters.push({
variantOption: {
name: filterKey,
value: val,
},
});
}
}
});
}
});
return filters;
};
Usage in Search Pageโ
app/search/page.tsx
import { getSearchResult } from "lib/nextshopkit/client";
import { parseFilters } from "lib/utils/parseFilters";
interface SearchPageProps {
searchParams: {
q?: string;
filter_available?: string;
filter_price?: string;
filter_productType?: string;
filter_productTag?: string[];
filter_color?: string;
filter_size?: string;
"filter_custom.category"?: string;
[key: string]: string | string[] | undefined;
};
}
export default async function SearchPage({ searchParams }: SearchPageProps) {
const query = searchParams.q || "";
if (!query) {
return <div>Enter a search term</div>;
}
// Parse filters from URL parameters
const productFilters = parseFilters(searchParams);
const { data, error } = await getSearchResult({
query,
limit: 24,
productFilters,
types: ["PRODUCT"],
unavailableProducts: "HIDE",
sortKey: "RELEVANCE",
});
if (error) {
return <div>Search failed: {error}</div>;
}
return (
<div className="container mx-auto px-4 py-8">
<h1>Search Results for "{data.searchTerm}"</h1>
<p>Found {data.totalCount} products</p>
<p>Applied {productFilters.length} filters</p>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mt-8">
{data.products.map((product) => (
<div key={product.id} className="border rounded-lg p-4">
<h3 className="font-semibold">{product.title}</h3>
<p>${product.price.amount}</p>
</div>
))}
</div>
</div>
);
}
Example URLs with Filtersโ
# Basic search
/search?q=solar+panels
# Search with availability filter
/search?q=solar+panels&filter_available=true
# Search with price range
/search?q=solar+panels&filter_price=100-1000
# Search with multiple product tags
/search?q=solar+panels&filter_productTag=renewable&filter_productTag=eco-friendly
# Search with variant options
/search?q=t-shirt&filter_color=blue&filter_size=large
# Search with custom metafields
/search?q=solar+kit&filter_custom.category=residential&filter_custom.power=9kw
# Complex search with multiple filters
/search?q=solar&filter_available=true&filter_price=500-2000&filter_productType=Solar+Panel&filter_custom.warranty=25
Building Filter URLsโ
components/FilterBuilder.tsx
"use client";
import { useRouter, useSearchParams } from "next/navigation";
export default function FilterBuilder() {
const router = useRouter();
const searchParams = useSearchParams();
const updateFilter = (filterKey: string, value: string | null) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(`filter_${filterKey}`, value);
} else {
params.delete(`filter_${filterKey}`);
}
router.push(`/search?${params.toString()}`);
};
const addTagFilter = (tag: string) => {
const params = new URLSearchParams(searchParams.toString());
params.append("filter_productTag", tag);
router.push(`/search?${params.toString()}`);
};
return (
<div className="space-y-4">
{/* Availability Filter */}
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={searchParams.get("filter_available") === "true"}
onChange={(e) =>
updateFilter("available", e.target.checked ? "true" : null)
}
/>
<span className="ml-2">Available only</span>
</label>
</div>
{/* Price Range Filter */}
<div>
<label>Price Range:</label>
<select
value={searchParams.get("filter_price") || ""}
onChange={(e) => updateFilter("price", e.target.value || null)}
>
<option value="">Any price</option>
<option value="0-100">Under $100</option>
<option value="100-500">$100 - $500</option>
<option value="500-1000">$500 - $1000</option>
<option value="1000-">Over $1000</option>
</select>
</div>
{/* Product Type Filter */}
<div>
<label>Product Type:</label>
<select
value={searchParams.get("filter_productType") || ""}
onChange={(e) => updateFilter("productType", e.target.value || null)}
>
<option value="">Any type</option>
<option value="Solar Panel">Solar Panel</option>
<option value="Inverter">Inverter</option>
<option value="Battery">Battery</option>
</select>
</div>
{/* Color Filter (Variant Option) */}
<div>
<label>Color:</label>
<select
value={searchParams.get("filter_color") || ""}
onChange={(e) => updateFilter("color", e.target.value || null)}
>
<option value="">Any color</option>
<option value="black">Black</option>
<option value="blue">Blue</option>
<option value="silver">Silver</option>
</select>
</div>
{/* Custom Metafield Filter */}
<div>
<label>Category:</label>
<select
value={searchParams.get("filter_custom.category") || ""}
onChange={(e) =>
updateFilter("custom.category", e.target.value || null)
}
>
<option value="">Any category</option>
<option value="residential">Residential</option>
<option value="commercial">Commercial</option>
<option value="industrial">Industrial</option>
</select>
</div>
</div>
);
}
This approach provides:
- URL-based filtering that's shareable and bookmarkable
- Flexible filter parsing that handles multiple value types
- Support for all filter types including metafields and variant options
- Easy integration with Next.js App Router search params
โ Next: Types Reference โ
Description
Practical examples of using getSearchResult for search pages, filtering, pagination, and more.