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 when building headless Shopify storefronts. 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.
Demo
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:
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.
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.
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.
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.
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.
typescript
// src/lib/shopify/queries/search.tsexportconst 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.
typescript
// src/lib/shopify/predictive-search.ts"use server";
import { predictiveSearchQuery } from"./queries/search";
// Construct the Shopify API endpointconst 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 versionconst token = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!; // Access token - never expose this client-side// Define the expected structure of a single search resultinterfacePredictiveSearchResult {
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.
*/exportasyncfunctiongetPredictiveSearchResults(query: string): Promise<PredictiveSearchResult["products"]> {
try {
const response = awaitfetch(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 unexpectedreturn 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 the NEXT_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, and toggle actions accessible by any component.
File:src/hooks/use-search-dialog-store.ts
typescript
import { create } from"zustand";
// Define the shape of our state and actionstypeSearchDialogState = {
isOpen: boolean;
open: () =>void;
close: () =>void;
toggle: () =>void;
};
exportconst useSearchDialogStore = create<SearchDialogState>((set) => ({
isOpen: false, // Initial state: dialog is closedopen: () =>set({ isOpen: true }), // Action to open the dialogclose: () =>set({ isOpen: false }), // Action to close the dialogtoggle: () =>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 sets isOpen to true.
close(): Explicitly sets isOpen to false.
toggle(): Inverts the current value of isOpen.
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. Call close() when the user clicks the backdrop or presses the Escape 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
typescript
"use client"; // This component needs to interact with client-side stateimport { useSearchDialogStore } from"src/hooks/use-search-dialog-store"; // Import our Zustand storeimport { MagnifyingGlass } from"lucide-react"; // Using lucide-react for the iconexportfunctionSearchTrigger() {
// Get the 'open' function from the storeconst open = useSearchDialogStore((state) => state.open);
return (
<buttontype="button"aria-label="Open search" // Accessibility:labelsthebuttonforscreenreadersonClick={open} // AttachtheopenactiontotheclickeventclassName="
p-2 rounded-md
hover:bg-gray-100
focus:outline-none focus:ring-2 focus:ring-indigo-500
"
><MagnifyingGlassclassName="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 to true in the Zustand store, preparing the SearchDialog 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 is true.
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
tsx
"use client"; // This component relies on client-side state and effectsimport { useState, useEffect, useRef, useTransition } from"react";
importLinkfrom"next/link"; // For navigation to product pagesimport { X } from"lucide-react"; // Close iconimport { useSearchDialogStore } from"src/hooks/use-search-dialog-store"; // Access to dialog stateimport { getPredictiveSearchResults } from"src/lib/shopify/predictive-search"; // Our server action// Re-declare the Product type to match the server action's return structuretypeProduct = {
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;
};
exportfunctionSearchDialog() {
// Zustand state for managing dialog visibilityconst isOpen = useSearchDialogStore((s) => s.isOpen);
const close = useSearchDialogStore((s) => s.close);
// Local state for managing search query and resultsconst [query, setQuery] = useState("");
const [results, setResults] = useState<Product[]>([]);
const [error, setError] = useState<string | null>(null);
// React concurrent features: useTransition for non-blocking updatesconst [isPending, startTransition] = useTransition();
// Ref for focusing the search input elementconst inputRef = useRef<HTMLInputElement>(null);
// Effect to manage focus and reset state when dialog opens/closesuseEffect(() => {
if (isOpen) {
// Use setTimeout to ensure the input is mounted before focusingconst focusTimeout = setTimeout(() => inputRef.current?.focus(), 100);
return() =>clearTimeout(focusTimeout);
} else {
// Reset state when dialog closessetQuery("");
setResults([]);
setError(null);
}
}, [isOpen]); // Dependency array: re-run when isOpen changes// Effect to debounce the user's input and fetch resultsuseEffect(() => {
if (!query) {
setResults([]); // Clear results if query is emptysetError(null);
return;
}
// Set a debounce timer (300ms)const timer = setTimeout(() => {
startTransition(() => {
// Mark this as a low-priority transitiongetPredictiveSearchResults(query)
.then((products) => {
setResults(products); // Update results statesetError(null); // Clear any previous errors
})
.catch((e) => {
console.error(e); // Log the errorsetError("Something went wrong. Please try again."); // Set user-facing error messagesetResults([]); // Clear results on error
});
});
}, 300); // 300ms debounce delay// Cleanup the timer if the component unmounts or query changes before timeoutreturn() =>clearTimeout(timer);
}, [query]); // Dependency array: re-run when query changes// If the dialog is not open, render nothingif (!isOpen) returnnull;
return (
// Full-screen overlay and dialog container<divclassName="fixed inset-0 z-50">
{/* Backdrop: semi-transparent overlay that closes the dialog on click */}
<divclassName="fixed inset-0 bg-black bg-opacity-50"onClick={close} />
{/* Dialog Panel: the main content area */}
<divclassName="relative mx-auto mt-20 max-w-xl bg-white rounded shadow-lg">
{/* Close Button: positioned at the top-right */}
<buttononClick={close}aria-label="Close search"className="absolute top-4 right-4 p-2 text-gray-500 hover:text-gray-700"
><XclassName="h-5 w-5" /></button>
{/* Search Input Area */}
<divclassName="p-4"><inputref={inputRef} // Attachtherefforfocusingtype="text"placeholder="Search products..."className="w-full border-b border-gray-300 p-2 focus:outline-none"value={query} // ControlledinputonChange={(e) => setQuery(e.target.value)} // Update query state on change
/>
</div>
{/* Results Display Area */}
<divclassName="max-h-64 overflow-y-auto px-4 pb-4">
{/* Loading State */}
{isPending && <pclassName="text-center text-gray-500">Loading...</p>}
{/* Error State */}
{error && <pclassName="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 && (
<pclassName="text-center text-gray-600">
No results found for “{query}”
</p>
)}
{/* Product Results List */}
<ul>
{results.map((product) => (
<likey={product.id}><Linkhref={`/products/${product.handle}`} // LinktotheproductpageclassName="flex items-center gap-4 p-2 hover:bg-gray-100 rounded"onClick={close} // Closethedialogwhenaproductisclicked
>
{product.featuredImage && (
<imgsrc={product.featuredImage.url}alt={product.featuredImage.altText ?? product.title} // Usealttextorfallbacktotitlewidth={48}height={48}className="rounded-sm"
/>
)}
<div><p>{product.title}</p><pclassName="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, and isPending (using React's useTransition 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 with Enter.
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:
tsx
// inside SearchDialog()const [activeIndex, setActiveIndex] = useState(-1); // Tracks the currently highlighted result indexconst optionRefs = useRef<Array<HTMLLIElement | null>>([]); // Refs for scrolling active item into view
Keyboard Handler
Implement this function within your SearchDialog component:
tsx
functiononKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
switch (e.key) {
case"ArrowDown":
e.preventDefault(); // Prevent default browser scrolling// Move focus down, clamping to the last itemsetActiveIndex((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 highlightedconst product = results[activeIndex];
close(); // Close the dialogwindow.location.href = `/products/${product.handle}`; // Navigate to the product
}
break;
case"Escape":
close(); // Close the dialogbreak;
}
}
Markup Changes
Integrate these attributes and structures into your JSX:
On the <input>:
tsx
<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>:
tsx
<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>
After activeIndex is updated, scroll the corresponding list item into view:
tsx
// Add this useEffect hook to your componentuseEffect(() => {
// Ensure optionRefs.current[activeIndex] exists and scroll it into viewconst 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:
bash
npm install quick-lru
# or
yarn add quick-lru
Create a cache utility file:
typescript
// src/lib/shopify/cache.tsimportQuickLRUfrom"quick-lru";
// Configure a cache with a max size of 100 entriesexportconst searchCache = newQuickLRU<string, any>({ maxSize: 100 });
Integrate caching into the server action:
typescript
// src/lib/shopify/predictive-search.tsimport { searchCache } from"./cache"; // Import the cacheexportasyncfunctiongetPredictiveSearchResults(query: string) {
const cacheKey = `search:${query}`; // Unique key for the query// Check if the result is already in cacheif (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.
{
isPending &&
// Render 5 skeleton loaders while data is loadingArray.from({ length: 5 }).map((_, i) =><SkeletonResultkey={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:
typescript
functionhighlight(text: string, query: string) {
// Split text by the query (case-insensitive), keeping the query as a delimiterconst parts = text.split(newRegExp(`(${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()
? <markkey={i}className="bg-yellow-200">{part}</mark>
: <spankey={i}>{part}</span>// Otherwise, wrap in a plain span
);
}
Apply this function when rendering the product title:
tsx
<p>{highlight(product.title, query)}</p>
10.4 Basic Analytics
Track search terms and clicks to understand user behavior.
Create an analytics.ts file:
typescript
// src/lib/analytics.ts// Example for Google Analytics / GTM dataLayerexportfunctiontrackSearch(query: string) {
window.dataLayer?.push({ event: "search", search_term: query });
}
exportfunctiontrackClick(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):
tsx
startTransition(() => {
trackSearch(query); // Track the search querygetPredictiveSearchResults(query).then(…);
});
On a result <li> click:
tsx
onClick={() => {
trackClick(product.handle); // Track which product was clickedclose();
}}
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:
typescript
// 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 resultsexportfunctionusePredictiveSearch(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 emptyreturn;
}
const timer = setTimeout(() => {
startTransition(() => {
getPredictiveSearchResults(query)
.then(setResults) // Directly update results state
.catch((e) =>setError("Failed to load results")); // Set error message
});
}, 300); // Debounce delayreturn() =>clearTimeout(timer); // Clear timeout on cleanup
}, [query]); // Re-run effect when query changesreturn { results, error, isPending };
}
Your SearchDialog component can then simplify its state management by importing and using this hook:
tsx
// Inside SearchDialog()import { usePredictiveSearch } from"src/hooks/use-predictive-search";
// ... other imports and stateconst { 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 the fetch 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:
typescript
import { vi, describe, it, expect } from"vitest";
import { getPredictiveSearchResults } from"./predictive-search";
// Mock the global fetch functionglobal.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 actiondescribe("getPredictiveSearchResults", () => {
it("correctly fetches and returns products", async () => {
const results = awaitgetPredictiveSearchResults("test"); // Call the function// Expect fetch to have been calledexpect(global.fetch).toHaveBeenCalledTimes(1);
// Expect the results array to have one itemexpect(results).toHaveLength(1);
// Expect the title of the first result to be correctexpect(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.
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.
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 or master) 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.
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 with href 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, the query is active, and results.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.