Search Products using useProductSearch in Supabase

Hi,

In my shop-mini app I am collecting some user profile data (like favourite brands, etc) that I want to share with OpenAI’s model as a prompt and ask it to suggest me products using the useProductSearch.

Right now I am using the useProductSearch directly inside my shop-mini to get the top 10 results, but is it possible to use Supabase to connect to OpenAI and send the user’s data as a prompt to suggest more relevant products?

If you want to connect to OpenAI API you need to use a custom backend (like Supabase) to proxy the requests from the mini.

You can send the array of products to your backend and add them to a prompt for OpenAI. Then send the response back to the mini.

Hey @Quique-Shopify actually the idea is to just provide a basic prompt to OpenAI like “My wife loves pink sneakers, and her favourite brand is Nike, I’m looking to buy something in the range of $50-$100“, and then have the AI model search through Shopify’s catalog and give the relevant results back to the shop-mini where we can display it. How can I achieve this?

I tried using the useProductSearch hook directly inside my shop-mini (no backend required) but that alone does not give me most relevant results, plus I think it would not work with "natural language” queries.

@Quique-Shopify I researched about Shopify MCP but it only lets us access products for a selected store. I wonder how for example “pair with” is able to search the whole catalogue in Shopify as MCP is per store? Do you have any idea? We are now thinking off using AI to get specific keywords out of the input fields and then using the native useProductSearch hook in mini search the keywords. Would that be a robust way you think?

1 Like

the approach would be to ask OpenAI for keywords for the search query, and then use the minis search hook.

In this way, OpenAI will translate from natural language to search query keywords for your search. There’s no other way at the moment

Hi @Quique-Shopify I have had similar experience, do you suggest we only use the useProductSearch hook without any filters and then use our backend AI to organize/filter the results based on the search query? because when i do use the filters i do not get the correct results. Also, i am getting some product currency in pounds and rest in dollars.

1 Like

Hi @Quique-Shopify can you suggest to us how we should handle the category for useProductSearch param?
Should we just put all prompts to query, or can we still add categories to filters with this format [“gid://shopify/TaxonomyCategory/el”]. I am concern about the accuracy of the result.

No, I think the more filters you add, the more accurate the response will be. Also, take into account that search results may vary per user, and especially per user’s country.

What I suggested was to use OpenAI (or any other LLM) to convert from a natural language query to what our hook expects, that it’s a set of keywords.

You can add the keywords you’re interested in your search query and also add category filters to narrow the search.

We faced the same issue in a Mini we’re developing. The solution we came up with is roughly what @Quique-Shopify suggested:

I thought I’d share the details, in case anyone else faces this problem.

In our mini, we created a component <ProductSearchProvider /> that the main <App /> has as a child. The job of ProductSearchProvider is to listen for search requests from our server, and perform the searches for the given client, passing back the results. It listens for events by opening an SSE channel to the server, using credentials as with the reference implementation on the custom backend ( shop-minis/supabase/README.md at main · Shopify/shop-minis · GitHub )

On the server, the channel is associated with a request queue for the given user. When the server needs to search for products for the user, it posts a request object onto that queue, which then gets sent via the SSE channel to the client.

When the request provider gets the request, it uses product search (via useProductSearch) to perform the result, posting the response to a route of the form /api/product-search/.

When the server gets the response, it resolves a promise with the value, making it available for whatever is waiting for it.

In code:

On the server, it looks roughly like this:

type Search = { numProducts: number, query: string, ...etc. }

// The queues representing SSE streams. A Queue<T> has the following methods:
// post(t: T):void - throws if there are no listeners
// on(type: "event", listener: (t:T) => void):void
// off(type: "event", listener: (t:T) => void):void)
// listenerCount(type:"event"):number
const sseQueues = new Map<UserId, Queue<Search & { reqId: string }>>();

// A map representing unresolved search requests
const awaitingReply = new Map<ReqId, { resolve: (_:SearchResponse)=>void, reject: (reason:string) => void }>()

// The route that creates the SSE channels to listen to
app.get("/api/search", async (req, res) => {
const userId = await auth(req); // a helper that checks the user is authenticated
const sseQueue = sseQueues.get(userId) ?? new Queue();
sseQueues.set(userId, sseQueue); // ensure that the queue is there

// An sse channel has methods post, and on("close").
// You can look here, as to how to build one:https://stackoverflow.com/questions/34657222/how-to-use-server-sent-events-in-express-js

const sseChannel = createSSEChannel(res);
const listen = (e:Search)=>{sseChannel.post(e)}
queue.on("event", listen); // post to all channels -- that way at least one Mini will response
sseChannel.on("close", () => {
queue.off("event", listen);
// ensure that the queue is removed when there are no more Minis listening to it
if (queue.listenerCount("event") === 0) { sseQueues.delete(userId); }
}
})

// The route that listens for responses
app.post("/api/search/:id", async (req, res) => {
const resolvers = awaitingReply.get(req.params.id);
if (!resolvers) {
// already resolved - first past the post wins.
res.sendStatus(200);
return;
}
const { resolve, reject } = resolvers;
try {
const result = parseSearchResult(res.body);
if (result.status === "ok") { resolve(result.payload); }
else { reject(new Error(result.reason)) }
awaitingReply.delete(req.params.id);
res.sendStatus(200);
return;
} catch (e) {
console.warn("Error parsing search result");
console.debug(e);
res.sendStatus(400);
}
});

/**
* Performs a search for the given user.
*/
async function productSearch(userId: UserId, search: Search, signal?: AbortSignal) {
const {promise, resolve, reject} = Promise.withResolvers<SearchResult>();
const queue = sseQueue.get(userId);
const requestId = crypto.randomUUID();
awaitingReply.set(requestingId, { resolve, reject })
if (!queue) { reject(new Error("No listeners")); awaitingReply.delete(requestId); }
queue.post(search).catch(e => {
console.warn(e);
reject("Error posting search");
awaitingReply.delete(requestId);
})
return promise;
}

In the mini, ProductSearchProvider looks like this:

function ProductSearchProvider() {
const [openRequests, setOpenRequests] = useState<Search & {reqId: string}>([])
useEffect(() => {
const channel = openSSEChannel(); // a small wrapper over SSE -Using server-sent events - Web APIs | MDN
channel.on("event", (e) => {
const req = parseEvent(e);
setOpenRequests(requests => requests.some(r => r.reqId === req.reqId) ? requests : [...requests, req]);
return () => { channel.close() }
}, [setOpenRequests]);
const onResponse = useCallback(async (queryId: string, res:SearchResult) => {
if (!openRequests.some(r => r.id === queryId)) { return; }
const res = await fetch(<API_ROUTE/queryId>, { method: "POST", body: JSON.stringify(res), ... });
if (!res.ok) {
console.warn("Bad response");
}
setOpenRequests(rs => rs.filter(r => r.id !== queryId))
}, [setOpenRequests, openRequests])

// The key is important -- it makes sure the ProductSearch stays associated with a particular request
return <>{openRequests.map(r => <ProductSearch search={r} onResponse={onResponse} key={r.reqId} />)}</>;
}

function ProductSearch({ search: { reqId, numProducts, ...searchParams }, onResponse }: { queryId: string, search: Search & { reqId: string }, onResponse: (queryId: string, res:SearchResult) => void }) {
const { error, fetchMore, hasNextPage, loading, products } = useSearchResults();
useEffect(() => {
if (loading) { return; }
if (error) {
console.log("Error in search:", error);
onResponse(reqId, { status: "error", reason: error });
return;
}
if (hasNextPage && (products ?? []).length < numProducts)) {
fetchMore();
return;
}
onResponse(reqId, { status: "ok", payload: { products } });
}, [loading, hasNextPage, fetchMore, error, products, onResponse]);
return null;
}

That is a basic sketch. I hope it helps anyone coming across this later.

Excuse any typos or errors – I’ve written this from memory and too little sleep. For production, there are a bunch of edge cases to take care of (concurrency across different machines etc), but this should show the basic idea.

1 Like