Building a Predictive Search Feature in Next.js with Shopify
Supercharge your Shopify storefront with a blazing-fast, predictive search using Next.js, Server Actions, Zustand, and input debouncing for a seamless user experience

In modern e-commerce, instant feedback is king. Users expect to see relevant product suggestions as they type their search queries. Our previous search flow was cumbersome: users had to type, press Enter, and wait for a full page reload to see results. This friction often led to cart abandonment.
Predictive search solves this by displaying results in real-time as characters are entered. It reduces wait times, keeps users engaged, and directly boosts conversions by guiding shoppers to the right products faster.
This guide will walk you through building a robust predictive search feature in a Next.js App Router application. You'll leverage server actions for secure Shopify Storefront API queries, manage dialog state with Zustand, and implement input debouncing for efficient API calls. The result will be a reusable, live-updating search component that significantly enhances your store's user experience.
Tech Stack
Our predictive search feature is powered by:
- Next.js: 15.x (App Router)
- React: 19.x
- TypeScript: 5.8
- Zustand: For state management
- shadcn/ui: UI primitives (v2.3+)
- Node.js: 24.x
- Tailwind CSS: Via shadcn/ui
- lucide-react: For icons
You can find the key files for this feature in the repository:
src/hooks/use-search-dialog-store.ts
src/components/search/search-dialog.tsx
src/components/search/search-trigger.tsx
src/lib/shopify/predictive-search.ts
src/lib/shopify/queries/search.ts
Here’s a simplified view of the project structure for this feature:
src/ ├── hooks/ │ └── use-search-dialog-store.ts ├── components/ │ └── search/ │ ├── search-dialog.tsx │ └── search-trigger.tsx └── lib/ └── shopify/ ├── predictive-search.ts └── queries/ └── search.ts
This structure clearly organizes the components, state management, and API interaction logic.
The Problem: Clunky Search Experience
Our previous search mechanism felt outdated. Users typed a product name, hit Enter, and then endured a full page reload. This interruption broke the user's flow and negatively impacted conversion rates.
We aimed for a seamless experience: live results appearing immediately as the user typed. However, directly querying the Shopify Storefront API from the client-side posed a security risk – exposing our access token within the public JavaScript bundle.
Furthermore, we identified performance bottlenecks. Firing an API request on every keystroke could quickly lead to exceeding rate limits and wasted bandwidth.
In essence, we needed to achieve:
- Instant feedback: Without disruptive page reloads.
- Server-side security: Keeping sensitive API keys protected on the server.
- Efficient request handling: Managing API calls intelligently as the user types.
Next, we'll explore how we broke down this solution into four fundamental parts.
Solution Overview: Four Core Pieces
We tackled this challenge by dividing the implementation into four distinct modules:
-
Zustand Store
- File:
src/hooks/use-search-dialog-store.ts
- Manages a simple boolean state to control the visibility of the search dialog. Allows any component to open or close it.
- File:
-
SearchDialog Component
- File:
src/components/search/search-dialog.tsx
- Renders the search modal. It captures user input, displays loading states, handles errors, shows "no results" messages, or lists matching products.
- File:
-
Server Action
- File:
src/lib/shopify/predictive-search.ts
- Executes on the server, making secure GraphQL requests to Shopify. This is where the access token remains protected.
- File:
-
GraphQL Query
- File:
src/lib/shopify/queries/search.ts
- Defines the
predictiveSearch
query structure, requesting essential product details like ID, title, handle, price, availability, and featured image.
- File:
We'll now implement each of these components step-by-step.
Phase 1: GraphQL Query & Secure Server Action
This phase focuses on defining the necessary GraphQL query and wrapping it in a secure server action for client-side invocation.
Writing the GraphQL Query
File: src/lib/shopify/queries/search.ts
This query requests up to a specified $limit
of products that match the $query
term from Shopify. It retrieves each product's ID, title, handle, availability status, price range, and featured image.
// src/lib/shopify/queries/search.ts
export const predictiveSearchQuery = `
query predictiveSearch($query: String!, $limit: Int!) {
predictiveSearch(
query: $query
limit: $limit
types: [PRODUCT] # Filter for product results
unavailableProducts: LAST # Push out-of-stock items to the end
) {
products {
id
title
handle
availableForSale
priceRange {
minVariantPrice {
amount
currencyCode
}
}
featuredImage {
url
altText
width
height
}
}
}
}
`;
Key Points:
$query
: The user's input string.$limit
: Caps the number of results returned (we'll use 5 in the component).unavailableProducts: LAST
: Configures the API to list out-of-stock items at the bottom of the results.
Creating the Secure Server Action
File: src/lib/shopify/predictive-search.ts
This server action, marked with "use server"
, securely fetches data. It reads Shopify store details and the access token from environment variables, ensuring the token never leaves the server. It then dispatches a POST request to the Storefront API. Any errors encountered during the process are logged, and an empty array is returned.
// src/lib/shopify/predictive-search.ts
"use server";
import { predictiveSearchQuery } from "./queries/search";
// Construct the Shopify API endpoint
const domain = process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN
? process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN.startsWith("https://")
? process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN
: `https://${process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN}`
: "";
const endpoint = `${domain}/api/2023-04/graphql.json`; // Using a specific API version
const token = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!; // Access token - never expose this client-side
// Define the expected structure of a single search result
interface PredictiveSearchResult {
products: Array<{
id: string;
title: string;
handle: string;
availableForSale: boolean;
priceRange: {
minVariantPrice: {
amount: string;
currencyCode: string;
};
};
featuredImage: {
url: string;
altText: string | null;
width: number;
height: number;
} | null;
}>;
}
/**
* Fetches predictive search results from the Shopify Storefront API.
* @param query The search term entered by the user.
* @returns A promise resolving to an array of product results.
*/
export async function getPredictiveSearchResults(
query: string
): Promise<PredictiveSearchResult["products"]> {
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Shopify-Storefront-Access-Token": token, // Securely include the token
},
body: JSON.stringify({
query: predictiveSearchQuery,
variables: { query, limit: 5 }, // Pass query and limit
}),
});
const json = await response.json();
if (json.errors) {
console.error("Shopify GraphQL errors:", json.errors);
return []; // Return empty array on GraphQL errors
}
// Return the product results, or an empty array if data structure is unexpected
return json.data?.predictiveSearch?.products || [];
} catch (err) {
console.error("Error in predictive search fetch:", err);
return []; // Return empty array on network or other errors
}
}
Explanation:
- We dynamically construct the
domain
from theNEXT_PUBLIC_SHOPIFY_STORE_DOMAIN
environment variable. - The
SHOPIFY_STOREFRONT_ACCESS_TOKEN
is securely accessed here and never exposed to the client. - Error handling is robust: network failures or GraphQL errors result in a logged message and an empty array (
[]
).
With the query and server action established, the client can now safely invoke getPredictiveSearchResults
. The next phase involves creating the modal UI to consume this data.
Phase 2: Managing Dialog State with Zustand
We are using lightweight state management lib to manage the state of the dialog. This way we can easily share its state between different parts of code.
Goal:
- Manage a single boolean flag to control the search modal's visibility.
- Provide simple
open
,close
, andtoggle
actions accessible by any component.
File: src/hooks/use-search-dialog-store.ts
import { create } from "zustand";
// Define the shape of our state and actions
type SearchDialogState = {
isOpen: boolean;
open: () => void;
close: () => void;
toggle: () => void;
};
export const useSearchDialogStore = create<SearchDialogState>((set) => ({
isOpen: false, // Initial state: dialog is closed
open: () => set({ isOpen: true }), // Action to open the dialog
close: () => set({ isOpen: false }), // Action to close the dialog
toggle: () => set((state) => ({ isOpen: !state.isOpen })), // Action to toggle visibility
}));
How it works:
isOpen
: A boolean state variable that tracks whether the dialog is currently visible.open()
: Explicitly setsisOpen
totrue
.close()
: Explicitly setsisOpen
tofalse
.toggle()
: Inverts the current value ofisOpen
.
Usage examples:
- Search Trigger Component: Import the store and call
open()
when a search icon or button is clicked. - Search Dialog Component: Read the
isOpen
state to conditionally render the modal. Callclose()
when the user clicks the backdrop or presses theEscape
key.
By centralizing this small piece of UI state in Zustand, we eliminate prop-drilling and ensure the search trigger and the dialog panel operate with complete decoupling. In the next section, we'll build the SearchTrigger
component that utilizes useSearchDialogStore.open()
, followed by the SearchDialog
itself.
Phase 3: The Search Trigger Component
Goal:
- Provide an accessible button anywhere in your UI that triggers the search dialog by calling
useSearchDialogStore.open()
. - Keep it flexible, allowing easy integration with custom styling or shadcn/ui's
Button
component.
File: src/components/search/search-trigger.tsx
"use client"; // This component needs to interact with client-side state
import { useSearchDialogStore } from "src/hooks/use-search-dialog-store"; // Import our Zustand store
import { MagnifyingGlass } from "lucide-react"; // Using lucide-react for the icon
export function SearchTrigger() {
// Get the 'open' function from the store
const open = useSearchDialogStore((state) => state.open);
return (
<button
type="button"
aria-label="Open search" // Accessibility: labels the button for screen readers
onClick={open} // Attach the open action to the click event
className="
p-2 rounded-md
hover:bg-gray-100
focus:outline-none focus:ring-2 focus:ring-indigo-500
"
>
<MagnifyingGlass className="h-5 w-5 text-gray-600" /> {/* The search icon */}
</button>
);
}
How it works:
- The
"use client"
directive is essential, allowing this component to hook into our Zustand store and use React hooks. - We extract the
open
action from the store and bind it to a standard<button>
element. - Basic Tailwind CSS classes provide hover and focus states. You can easily replace this button with a styled component from
shadcn/ui
if preferred.
Where to put it:
- Integrate
<SearchTrigger />
into your application's header or navigation component. - Clicking this button will set
isOpen
totrue
in the Zustand store, preparing theSearchDialog
to be displayed.
Now, let's move on to Phase 4, where we'll build the actual SearchDialog
modal, connect the input, debounce queries, call our server action, and render the live results.
Phase 4: Building the Search Dialog Component
Goal:
- Render a full-screen modal when
isOpen
istrue
. - Capture user input, debounce it, and trigger our server action.
- Display loading indicators, errors, "no results" messages, or the list of products.
- Ensure proper state cleanup when the dialog closes and the input receives focus on opening.
File: src/components/search/search-dialog.tsx
"use client"; // This component relies on client-side state and effects
import { useState, useEffect, useRef, useTransition } from "react";
import Link from "next/link"; // For navigation to product pages
import { X } from "lucide-react"; // Close icon
import { useSearchDialogStore } from "src/hooks/use-search-dialog-store"; // Access to dialog state
import { getPredictiveSearchResults } from "src/lib/shopify/predictive-search"; // Our server action
// Re-declare the Product type to match the server action's return structure
type Product = {
id: string;
title: string;
handle: string;
availableForSale: boolean;
priceRange: {
minVariantPrice: {
amount: string;
currencyCode: string;
};
};
featuredImage: {
url: string;
altText: string | null;
width: number;
height: number;
} | null;
};
export function SearchDialog() {
// Zustand state for managing dialog visibility
const isOpen = useSearchDialogStore((s) => s.isOpen);
const close = useSearchDialogStore((s) => s.close);
// Local state for managing search query and results
const [query, setQuery] = useState("");
const [results, setResults] = useState<Product[]>([]);
const [error, setError] = useState<string | null>(null);
// React concurrent features: useTransition for non-blocking updates
const [isPending, startTransition] = useTransition();
// Ref for focusing the search input element
const inputRef = useRef<HTMLInputElement>(null);
// Effect to manage focus and reset state when dialog opens/closes
useEffect(() => {
if (isOpen) {
// Use setTimeout to ensure the input is mounted before focusing
const focusTimeout = setTimeout(() => inputRef.current?.focus(), 100);
return () => clearTimeout(focusTimeout);
} else {
// Reset state when dialog closes
setQuery("");
setResults([]);
setError(null);
}
}, [isOpen]); // Dependency array: re-run when isOpen changes
// Effect to debounce the user's input and fetch results
useEffect(() => {
if (!query) {
setResults([]); // Clear results if query is empty
setError(null);
return;
}
// Set a debounce timer (300ms)
const timer = setTimeout(() => {
startTransition(() => {
// Mark this as a low-priority transition
getPredictiveSearchResults(query)
.then((products) => {
setResults(products); // Update results state
setError(null); // Clear any previous errors
})
.catch((e) => {
console.error(e); // Log the error
setError("Something went wrong. Please try again."); // Set user-facing error message
setResults([]); // Clear results on error
});
});
}, 300); // 300ms debounce delay
// Cleanup the timer if the component unmounts or query changes before timeout
return () => clearTimeout(timer);
}, [query]); // Dependency array: re-run when query changes
// If the dialog is not open, render nothing
if (!isOpen) return null;
return (
// Full-screen overlay and dialog container
<div className="fixed inset-0 z-50">
{/* Backdrop: semi-transparent overlay that closes the dialog on click */}
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={close} />
{/* Dialog Panel: the main content area */}
<div className="relative mx-auto mt-20 max-w-xl bg-white rounded shadow-lg">
{/* Close Button: positioned at the top-right */}
<button
onClick={close}
aria-label="Close search"
className="absolute top-4 right-4 p-2 text-gray-500 hover:text-gray-700"
>
<X className="h-5 w-5" />
</button>
{/* Search Input Area */}
<div className="p-4">
<input
ref={inputRef} // Attach the ref for focusing
type="text"
placeholder="Search products..."
className="w-full border-b border-gray-300 p-2 focus:outline-none"
value={query} // Controlled input
onChange={(e) => setQuery(e.target.value)} // Update query state on change
/>
</div>
{/* Results Display Area */}
<div className="max-h-64 overflow-y-auto px-4 pb-4">
{/* Loading State */}
{isPending && <p className="text-center text-gray-500">Loading...</p>}
{/* Error State */}
{error && <p className="text-center text-red-500">{error}</p>}
{/* No Results State: shown when query is active, no pending state, no error, and no results */}
{!isPending && !error && query && results.length === 0 && (
<p className="text-center text-gray-600">
No results found for “{query}”
</p>
)}
{/* Product Results List */}
<ul>
{results.map((product) => (
<li key={product.id}>
<Link
href={`/products/${product.handle}`} // Link to the product page
className="flex items-center gap-4 p-2 hover:bg-gray-100 rounded"
onClick={close} // Close the dialog when a product is clicked
>
{product.featuredImage && (
<img
src={product.featuredImage.url}
alt={product.featuredImage.altText ?? product.title} // Use alt text or fallback to title
width={48}
height={48}
className="rounded-sm"
/>
)}
<div>
<p>{product.title}</p>
<p className="text-sm text-gray-500">
{product.priceRange.minVariantPrice.currencyCode}{" "}
{product.priceRange.minVariantPrice.amount}
</p>
</div>
</Link>
</li>
))}
</ul>
</div>
</div>
</div>
);
}
Explanation of key bits:
"use client"
: Enables the use of React hooks and client-side interactions.- State management: We track
query
,results
,error
, andisPending
(using React'suseTransition
for non-blocking updates). - Debouncing: A 300ms delay is applied using
setTimeout
to prevent excessive API calls on every keystroke. startTransition
: Marks the API fetch as a low-priority task, ensuring the UI remains responsive.- Interaction: Clicking the backdrop or the "X" button calls the
close()
action from the Zustand store. - State Reset: All local state (
query
,results
,error
) is cleared when the dialog closes, ensuring a fresh state for the next opening. - Navigation: Each search result is a
Link
to its respective product page, and clicking it also closes the dialog.
With these components in place, your live predictive search is functional:
- Trigger: Accessible from anywhere via the
SearchTrigger
component. - State: Managed centrally with the Zustand store.
- Data Fetching: Secure and efficient using server actions.
- UI: Responsive and real-time with debounced client-side logic.
Next up: Phase 5, where we'll enhance the user experience with keyboard navigation and accessibility improvements.
Phase 5: Keyboard Navigation & Accessibility
Goal:
- Enable users to navigate search results using arrow keys (
↑
/↓
) and select withEnter
. - Improve accessibility by announcing the dialog's open state and the currently active item for screen readers.
- Ensure the dialog closes gracefully via
Escape
key or clicking outside.
We'll enhance the existing SearchDialog
component by adding state for the active index, implementing keyboard event handlers, and integrating ARIA attributes.
State & Refs
Add these within your SearchDialog()
function:
// inside SearchDialog()
const [activeIndex, setActiveIndex] = useState(-1); // Tracks the currently highlighted result index
const optionRefs = useRef<Array<HTMLLIElement | null>>([]); // Refs for scrolling active item into view
Keyboard Handler
Implement this function within your SearchDialog
component:
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
switch (e.key) {
case "ArrowDown":
e.preventDefault(); // Prevent default browser scrolling
// Move focus down, clamping to the last item
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
break;
case "ArrowUp":
e.preventDefault(); // Prevent default browser scrolling
// Move focus up, clamping to the first item (index 0)
setActiveIndex((i) => Math.max(i - 1, 0));
break;
case "Enter":
if (activeIndex >= 0) {
// If an item is highlighted
const product = results[activeIndex];
close(); // Close the dialog
window.location.href = `/products/${product.handle}`; // Navigate to the product
}
break;
case "Escape":
close(); // Close the dialog
break;
}
}
Markup Changes
Integrate these attributes and structures into your JSX:
On the <input>
:
<input
ref={inputRef}
type="text"
placeholder="Search products..."
className="w-full border-b border-gray-300 p-2 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
// Accessibility enhancements:
role="combobox" // Identifies the input as a combobox
aria-expanded={isOpen} // Indicates if the dropdown is expanded
aria-controls="search-listbox" // Links to the listbox it controls
aria-activedescendant={activeIndex >= 0 ? `option-${activeIndex}` : undefined} // Points to the currently active option
onKeyDown={onKeyDown} // Attach the keyboard handler
/>
On the <ul>
:
<ul
role="listbox" // Identifies this as a listbox for screen readers
id="search-listbox" // ID referenced by aria-controls
className="max-h-64 overflow-y-auto px-4 pb-4"
>
{/* ... mapping results ... */}
</ul>
On each <li>
:
{
results.map((product, idx) => (
<li
key={product.id}
id={`option-${idx}`} // Unique ID for aria-activedescendant
role="option" // Identifies this list item as an option
aria-selected={activeIndex === idx} // Indicates if this option is currently selected
ref={(el) => (optionRefs.current[idx] = el)} // Attach ref for scrolling
className={`flex items-center gap-4 p-2 rounded cursor-pointer ${
activeIndex === idx
? "bg-indigo-100" // Highlight style when active
: "hover:bg-gray-100" // Hover style otherwise
}`}
onMouseEnter={() => setActiveIndex(idx)} // Update active index on hover
onClick={() => {
close();
// Navigate to product logic here, e.g.:
// window.location.href = `/products/${product.handle}`;
}}
>
{/* ... product image, title, price ... */}
</li>
));
}
Scrolling the Active Item into View
After activeIndex
is updated, scroll the corresponding list item into view:
// Add this useEffect hook to your component
useEffect(() => {
// Ensure optionRefs.current[activeIndex] exists and scroll it into view
const el = optionRefs.current[activeIndex];
if (el) {
el.scrollIntoView({ block: "nearest" }); // Scroll to bring it into viewport without shifting too much
}
}, [activeIndex]); // Re-run when activeIndex changes
With these enhancements:
- Screen readers can now understand the combobox state (open/closed) and identify the selected option.
- Keyboard-only users can navigate results effectively and select them with
Enter
. - The
Escape
key and clicking outside the dialog now provide a consistent closing behavior.
This makes your predictive search feature much more accessible and user-friendly.
Phase 6: Productionizing & Advanced Features
Goal:
- Implement caching to minimize redundant network calls.
- Introduce skeleton loaders for a smoother visual experience.
- Highlight matching text fragments within product titles.
- Integrate basic analytics tracking for search events.
- Extract reusable components/hooks and add tests.
10.1 Caching with an LRU Cache
Add a simple server-side cache for recent queries using quick-lru
.
Install the package:
npm install quick-lru
# or
yarn add quick-lru
Create a cache utility file:
// src/lib/shopify/cache.ts
import QuickLRU from "quick-lru";
// Configure a cache with a max size of 100 entries
export const searchCache = new QuickLRU<string, any>({ maxSize: 100 });
Integrate caching into the server action:
// src/lib/shopify/predictive-search.ts
import { searchCache } from "./cache"; // Import the cache
export async function getPredictiveSearchResults(query: string) {
const cacheKey = `search:${query}`; // Unique key for the query
// Check if the result is already in cache
if (searchCache.has(cacheKey)) {
return searchCache.get(cacheKey);
}
// ... perform fetch as before ...
const json = await response.json();
const products = json.data?.predictiveSearch?.products || [];
// Store the fetched results in the cache before returning
searchCache.set(cacheKey, products);
return products;
}
10.2 Skeleton Loader UI
Replace the plain "Loading..." text with visual skeleton placeholders for a smoother perceived performance.
Create a SkeletonResult
component:
function SkeletonResult() {
return (
<div className="flex items-center gap-4 p-2 animate-pulse">
<div className="w-12 h-12 bg-gray-200 rounded" />{" "}
{/* Placeholder for image */}
<div className="flex-1 space-y-2 py-1">
<div className="h-4 bg-gray-200 rounded" />{" "}
{/* Placeholder for title */}
<div className="h-4 w-1/2 bg-gray-200 rounded" />{" "}
{/* Placeholder for price */}
</div>
</div>
);
}
Use it in the SearchDialog
's results rendering:
{
isPending &&
// Render 5 skeleton loaders while data is loading
Array.from({ length: 5 }).map((_, i) => <SkeletonResult key={i} />);
}
10.3 Highlight Matching Text
Visually highlight the parts of the product title that match the user's query.
Create a highlight
utility function:
function highlight(text: string, query: string) {
// Split text by the query (case-insensitive), keeping the query as a delimiter
const parts = text.split(new RegExp(`(${query})`, "gi"));
return parts.map((part, i) =>
// If the part matches the query (case-insensitive), wrap it in a <mark> tag
part.toLowerCase() === query.toLowerCase()
? <mark key={i} className="bg-yellow-200">{part}</mark>
: <span key={i}>{part}</span> // Otherwise, wrap in a plain span
);
}
Apply this function when rendering the product title:
<p>{highlight(product.title, query)}</p>
10.4 Basic Analytics
Track search terms and clicks to understand user behavior.
Create an analytics.ts
file:
// src/lib/analytics.ts
// Example for Google Analytics / GTM dataLayer
export function trackSearch(query: string) {
window.dataLayer?.push({ event: "search", search_term: query });
}
export function trackClick(productHandle: string) {
window.dataLayer?.push({ event: "search_click", product: productHandle });
}
Call these functions at appropriate points:
In your debounced effect (when a search is initiated):
startTransition(() => {
trackSearch(query); // Track the search query
getPredictiveSearchResults(query).then(…);
});
On a result <li>
click:
onClick={() => {
trackClick(product.handle); // Track which product was clicked
close();
}}
10.5 Extracting a Reusable Hook/Component
Encapsulate the core search logic into a custom hook for better reusability and testability.
Create src/hooks/use-predictive-search.ts
:
// src/hooks/use-predictive-search.ts
"use client";
import { useState, useTransition, useEffect } from "react";
import { getPredictiveSearchResults } from "src/lib/shopify/predictive-search";
// Hook to encapsulate the fetching and state management for search results
export function usePredictiveSearch(query: string) {
const [results, setResults] = useState([]);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (!query) {
setResults([]); // Clear results if query is empty
return;
}
const timer = setTimeout(() => {
startTransition(() => {
getPredictiveSearchResults(query)
.then(setResults) // Directly update results state
.catch((e) => setError("Failed to load results")); // Set error message
});
}, 300); // Debounce delay
return () => clearTimeout(timer); // Clear timeout on cleanup
}, [query]); // Re-run effect when query changes
return { results, error, isPending };
}
Your SearchDialog
component can then simplify its state management by importing and using this hook:
// Inside SearchDialog()
import { usePredictiveSearch } from "src/hooks/use-predictive-search";
// ... other imports and state
const { results, error, isPending } = usePredictiveSearch(query);
// ... rest of the component uses these values
10.6 Testing
Robust testing ensures reliability.
- Unit tests: Use Vitest or Jest to test
getPredictiveSearchResults
by mocking thefetch
API. - Component tests: Use React Testing Library to test the
usePredictiveSearch
hook, advancing timers to simulate debounce delays. - End-to-end (E2E) tests: Employ Cypress or Playwright to test the full user flow: opening the dialog, typing, navigating results with keys, and confirming navigation upon pressing Enter.
Example Vitest for Server Action:
import { vi, describe, it, expect } from "vitest";
import { getPredictiveSearchResults } from "./predictive-search";
// Mock the global fetch function
global.fetch = vi.fn(() =>
Promise.resolve({
json: () =>
Promise.resolve({
data: {
predictiveSearch: {
products: [
{
id: "1",
title: "Test Product",
handle: "test-product",
availableForSale: true,
priceRange: {
minVariantPrice: { amount: "10", currencyCode: "USD" },
},
featuredImage: null,
},
],
},
},
}),
})
);
// Describe the tests for the server action
describe("getPredictiveSearchResults", () => {
it("correctly fetches and returns products", async () => {
const results = await getPredictiveSearchResults("test"); // Call the function
// Expect fetch to have been called
expect(global.fetch).toHaveBeenCalledTimes(1);
// Expect the results array to have one item
expect(results).toHaveLength(1);
// Expect the title of the first result to be correct
expect(results[0].title).toBe("Test Product");
});
});
With these advanced features, your live search becomes:
- Efficient: Cache-friendly and reduces duplicate API calls.
- Visually Smooth: Uses skeleton loaders for better perceived performance.
- Accessible: Fully keyboard-navigable and screen-reader friendly.
- Insightful: Tracks user interactions via analytics.
- Maintainable: Logic is extracted into reusable hooks and thoroughly tested.
Congratulations – you now have a production-ready, Shopify-powered predictive search component!
Final Steps: Configuration, Deployment & Maintenance
You've successfully transformed a basic search input into a sophisticated, high-performance, and accessible predictive search experience for your Shopify store.
11.1 Environment Variables
Ensure these variables are correctly configured in your deployment environment:
NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN
- Your shop's domain (e.g.,
my-shop.myshopify.com
). - This is safe to expose client-side as it's part of the public Shopify URL.
- Your shop's domain (e.g.,
SHOPIFY_STOREFRONT_ACCESS_TOKEN
- A read-only Storefront API access token.
- Crucially, this must only reside on the server and never be exposed in the browser.
11.2 Project File Structure
A clean structure aids maintainability:
src/ ├── components/ │ └── search/ │ ├── search-trigger.tsx │ └── search-dialog.tsx ├── hooks/ │ ├── use-search-dialog-store.ts │ └── use-predictive-search.ts ├── lib/ │ └── shopify/ │ ├── cache.ts │ ├── queries/ │ │ └── search.ts │ └── predictive-search.ts └── lib/analytics.ts
11.3 Deploying to Vercel (or Similar Platforms)
- Push your code to a Git repository (e.g., GitHub, GitLab).
- Connect your repository to your deployment platform (e.g., Vercel).
- Navigate to your project's settings on the platform. Under "Environment Variables," add:
NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN
SHOPIFY_STOREFRONT_ACCESS_TOKEN
- Configure your "Production Branch" (commonly
main
ormaster
) and initiate a deployment. - The platform will build your Next.js application, including Server Actions, and deploy it globally.
11.4 Performance & Caching
- The integrated LRU cache significantly reduces redundant calls to the Shopify Storefront API.
- Leverage CDN caching for static assets like CSS, JavaScript, and fonts.
- For product images, consider using Next.js's
Image
component or a dedicated CDN for optimized delivery. - Continuously monitor your application's bundle size and ensure effective tree-shaking for unused libraries or icons.
11.5 Accessibility & SEO
- Regularly validate your implementation using tools like Lighthouse or axe-core:
- Confirm the dialog uses correct ARIA roles (
combobox
,listbox
,option
). - Ensure focus management within the modal is robust (optional but recommended: focus trapping).
- Verify that screen readers announce dialog states (open/closed) and active items.
- Confirm the dialog uses correct ARIA roles (
- The search input should have a clear
aria-label
or an associated visible<label>
. - Product links rendered using the
Link
component correctly output<a>
tags withhref
attributes, which is crucial for SEO.
11.6 Troubleshooting & Tips
- 401/403 Errors: These typically indicate an issue with your Shopify access token or store domain. Double-check your environment variables.
- CORS or Network Errors: Verify that your API endpoint is correctly formatted (
https://{domain}/api/{version}/graphql.json
). - Sluggish Debounce: If the debounce delay feels too long, experiment with a lower value (e.g., 200ms).
- "No Results" Flicker: Ensure the "No results found" message is only displayed when
!isPending
, thequery
is active, andresults.length === 0
. - Debugging API Calls: Logging GraphQL errors directly from the API response is invaluable for diagnosing issues, especially after schema changes.
You've now successfully implemented a feature-rich, production-ready predictive search for your Shopify store, built with Next.js, Server Actions, and Zustand.
This solution is:
- Secure: With server-side data fetching.
- Responsive: Using Zustand for state and debounced client UI.
- Accessible: Supporting keyboard navigation and screen readers.
- Optimized: Featuring caching, skeleton loaders, and analytics hooks.
Thanks, Matija