How I built a product visual search using react-native, Google Cloud Vision API , Algolia and Remix.js
by Ilias Haddad
As part of my journey learning react native, I was thinking about building something cool that is related to the thing I enjoy talking about which is Shopify.
So, I decided to add a feature to the Shop app. that will allow user search in Shopify stores product catalog using an image instead of the keywords.
The workflow:
- A user scrolling through Instagram and the found a product that they like
- Take a screenshot of it
- Open the mobile app, click on the camera icon.
- Select the image and let the magic happen
- Get a list back of similar looking products across different Shopify store (in our case, it’ll be one Shopify Store)
Architectural Overview
The solution leverages a robust technology stack:
- React Native for cross-platform mobile development
- Google Cloud Vision API for image classification
- Algolia Search for efficient product indexing and retrieval
- Remix.js for backend API handling
- Shopify Storefront API for product data management
Developer Note: Keeping it Simple
While building this demo, I intentionally simplified many aspects that would be crucial for a production system. Here's why:
The main goal was to demonstrate how to:
- Connect Google Cloud Vision API with Shopify product search
- Process product images to enable visual search
- Create a basic mobile interface for image uploads
I deliberately omitted production concerns like error handling, security, and scalability because:
- This is a learning demo focused on the core visual search workflow
- Adding production-grade features would obscure the main technical concepts
- Each production requirement (auth, caching, monitoring etc.) deserves its own deep dive
Think of this implementation as a "proof of concept" that shows:
- The basic architecture works
- Visual search can enhance product discovery
- The core APIs integrate successfully
For anyone looking to build on this demo, you'd want to address production concerns based on your specific needs - like authentication, error handling, and performance optimization. But those additions shouldn't overshadow understanding the core visual search mechanics demonstrated here.
The goal was to keep the code approachable and focused on demonstrating value, rather than building a production-ready system. This lets developers understand the core concepts first, then layer in production requirements as needed.
Technical Implementation
Step 1: Labeling the Shopify products images
In order to label the Shopify products images, I used the Google Cloud Vision API to label the product variant image. Here’s an example code
const vision = require("@google-cloud/vision");// Instantiate Google Vision clientconst client = new vision.ImageAnnotatorClient({keyFilename: ""// Path to your service account key file here});export const getImageLabels = async (imageURL, objectID) => {const [result] = await client.labelDetection(imageURL);const labels = result.labelAnnotations.filter((label) => label.score > 0.5).map((label) => ({description: label.description,score: label.score,}));return { objectID, labels, imageURL };};
In the getImageLabels
function, I passed the imageURL
from the Shopify query results for each product variant image on each Shopify product. Using Shopify Storefront API, here’s the example code of it.
const getAllProducts = async () => {const productQuery = `{products(first: 250) {edges {node {idvariants(first: 250) {edges {node {idimage {url}}}}}}}}`;const { data, errors } = await ShopifyClient.request(productQuery);if (errors) {console.error(errors);}const products = data.products.edges;const productImages = products.map((product) => {const productNode = product.node;const productVariants = productNode.variants.edges;const productImages = productVariants.map((variant) => {const variantNode = variant.node;const image = variantNode.image;if (!image) return;return {imageURL: image.url,objectID: `${productNode.id}-${variantNode.id}`,};});return productImages;});const flatProductImages = productImages.flat();const allImageLabels = await Promise.all(flatProductImages.map(async (image) => {if (!image) return;return await getImageLabels( image.imageURL, image.objectID, image.imageURL);}));}
Key implementation highlights:
- Filtering labels with confidence scores above 0.5
- Extracting comprehensive image metadata
- Preparing data for subsequent indexing
Step 2: Indexing the Shopify products images with their labels
After getting the image labels, I’ll be using Algolia Search API and each product is indexed with a unique identifier combining product and variant IDs, along with associated image labels.
Indexing strategy:
- Unique object identification
- Comprehensive label preservation
- Efficient search optimization
Here’s an example code snippet:
const { searchClient } = require("@algolia/client-search");const algoliaClient = searchClient("","");const getAllProducts = async () => {// ...previous codeconst indexedImages = await Promise.all(allImageLabels.map(async (image) => {if (!image) {console.log(`warning: ${image} is not an image`);return;}return await algoliaClient.saveObject({indexName: "products",body: image,});}));}
Awesome, we made a great progress to index the product using Algolia and here’s a screenshot of the dashboard in my Algolia Dashboard.
Step 3: Visual Search API Endpoint
The Remix.js
backend action function serves as the core of our visual search mechanism. It processes base64-encoded images, generates labels, and performs a sophisticated search across the product index.
Search workflow:
- Image base64 decoding
- Label generation
- Algolia search with optional filters
- Unique product ID extraction
Here’s a snippet code of my Remix.js
action function
import type { LoaderFunctionArgs } from '@remix-run/cloudflare';import { json } from '@remix-run/react';import {getImageLabels,reduceLabelsToFilters,} from '../services/vision.server';import { algoliaClient } from '../services/algolia.server';export async function action({ request }: LoaderFunctionArgs) {const formData = await request.formData();const image = formData.get('image') as string; // Image Base64if (!image) {return json({ error: 'No image provided' }, { status: 400 });}const classifiedImage = await getImageLabels(image);const labels = reduceLabelsToFilters(classifiedImage.labels);const indexName = 'products';const { results } = await algoliaClient.search({requests: [{indexName,optionalFilters: labels,query: classifiedImage.labels.map(label => label.description).join(' '),},],});const products = results[0].hits.map(product => product.objectID.split('-')[0],);// remove duplicatesconst uniqueProducts = [...new Set(products)];console.log(uniqueProducts);return json({ results: uniqueProducts });}
Step 4: Build a mobile app to upload the image
The React Native mobile application provides an intuitive interface for visual product search. Key components include:
- Image picker functionality
- Seamless API integration
Here’s the code snippet responsible for handle image search.
// components/searchBar.tsximport { useState } from "react";import { TextInput, useColorScheme } from "react-native";import {launchImageLibrary,type ImageLibraryOptions,type ImagePickerResponse,} from "react-native-image-picker";import Ionicons from "@expo/vector-icons/Ionicons";import { Box } from "./Container";import theme, { darkTheme } from "@/theme";const ShopSearchBar = ({onSearch,placeholder = "Search products",setSelectedImage,setSearching,}: {onSearch?: (query: string) => void;onFilterPress?: () => void;placeholder?: string;setSelectedImage: (image: string | null) => void;setSearching: (searching: boolean) => void;}) => {const colorSchema = useColorScheme();const [searchQuery, setSearchQuery] = useState("");const openImagePicker = () => {const options: ImageLibraryOptions = {mediaType: "photo",includeBase64: true,maxHeight: 2000,maxWidth: 2000,};launchImageLibrary(options, (response: ImagePickerResponse) => {if (response.didCancel) {console.log("User cancelled image picker");} else if (response.errorMessage) {console.log("Image picker error: ", response.errorMessage);} else if (response.assets) {let imageUri = response.assets[0].base64;if (imageUri) {setSelectedImage(imageUri);setSearching(true);}}});};const handleSearch = () => {if (onSearch) {onSearch(searchQuery);}};return (<BoxflexDirection="row"padding="s"marginRight="s"marginLeft="s"backgroundColor="cardPrimaryBackground"borderRadius="m"><BoxflexDirection="row"padding="s"flex={1}justifyContent="space-between"alignContent="center"><Ioniconsname="search"size={20}color={colorSchema === "dark"? darkTheme.colors.iconColor: theme.colors.iconColor}/><TextInputplaceholder={placeholder}placeholderTextColor={colorSchema === "dark"? darkTheme.colors.placeholderText: theme.colors.placeholderText}value={searchQuery}onChangeText={setSearchQuery}onSubmitEditing={handleSearch}clearButtonMode="while-editing"autoCapitalize="none"autoCorrect={false}allowFontScaling={true}/><Ioniconsname="camera"size={20}color={colorSchema === "dark"? darkTheme.colors.iconColor: theme.colors.iconColor}onPress={openImagePicker}/></Box></Box>);};export default ShopSearchBar;
And this is a screenshot of the app:
Step 5: Link the mobile app with the API
The final step connects the mobile application with the backend API, enabling users to upload images and receive relevant product recommendations dynamically.
// lib/search.tsexport const searchByImage = async (image: string) => {const formdata = new FormData();formdata.append("image", image);const requestOptions = {method: "POST",body: formdata,};const response = await fetch(`${process.env.EXPO_PUBLIC_APP_HOST}/search`,requestOptions);const data = await response.json();return data.results;};
Server Setup
I used Remix.js Cloudflare Template
I made an API route for searching using an image in base64
format using Algolia Search API which I used before to index our Shopify product image with image labels we got from Google Cloud Vision API. I used the Shopify product variant id and product id as an index.
// app/routes/search.tsximport type { LoaderFunctionArgs } from '@remix-run/cloudflare';import { json } from '@remix-run/react';import {getImageLabels,reduceLabelsToFilters,} from '../services/vision.server';import { algoliaClient } from '../services/algolia.server';export async function action({ request }: LoaderFunctionArgs) {const formData = await request.formData();const image = formData.get('image') as string; // Image Base64if (!image) {return json({ error: 'No image provided' }, { status: 400 });}const classifiedImage = await getImageLabels(image);const labels = reduceLabelsToFilters(classifiedImage.labels);const indexName = 'products';const { results } = await algoliaClient.search({requests: [{indexName,optionalFilters: labels,query: classifiedImage.labels.map(label => label.description).join(' '),},],});// ObjectID is a unique identifier for each image in Algolia, Example: "1234-54355", first part is the product ID and the second part is the variant ID// We're indexing each variant image separately, and we're using the product ID to get parent product informationconst products = results[0].hits.map(product => product.objectID && product.objectID.split('-')[0],);return json({ results: products });}
Here's our Algolia Search API client:
// app/services/algolia.server.tsimport { searchClient } from '@algolia/client-search';import { configDotenv } from 'dotenv';configDotenv({path: '.dev.vars',});if (!process.env.ALGOLIA_APP_ID) {throw new Error('Missing Algolia App ID. Please provide it in the .env file.',);}if (!process.env.ALGOLIA_SEARCH_KEY) {throw new Error('Missing Algolia Search Key. Please provide it in the .env file.',);}export const algoliaClient = searchClient(process.env.ALGOLIA_APP_ID,process.env.ALGOLIA_SEARCH_KEY,);
Here's our Shopify Storefront API client:
// app/services/shopify.server.tsimport { createStorefrontApiClient } from '@shopify/storefront-api-client';import { configDotenv } from 'dotenv';configDotenv({path: '.dev.vars',});if (!process.env.STORE_DOMAIN) {throw new Error('Missing Store Domain. Please provide it in the .env file.');}if (!process.env.STOREFRONT_ACCESS_TOKEN) {throw new Error('Missing Storefront Access Token. Please provide it in the .env file.',);}export const ShopifyClient = createStorefrontApiClient({storeDomain: process.env.STORE_DOMAIN,publicAccessToken: process.env.STOREFRONT_ACCESS_TOKEN,apiVersion: '2024-10',});
And our Google Cloud Vision API to get image labels by passing an image in base64
format
// app/services/vision.server.tsimport vision from '@google-cloud/vision';import { configDotenv } from 'dotenv';configDotenv({path: '.dev.vars',});if (!process.env.GOOGLE_APPLICATION_CREDENTIALS_BASE64) {throw new Error('Missing Google Application Credentials. Please provide it in the .env file.',);}const credentials = JSON.parse(Buffer.from(process.env.GOOGLE_APPLICATION_CREDENTIALS_BASE64,'base64',).toString('utf-8'),);const client = new vision.ImageAnnotatorClient({credentials,});type Label = {description: string | null | undefined;score: number | null | undefined;};export const getImageLabels = async (imageBase64: string) => {const [result] = await client.annotateImage({image: {content: imageBase64,},features: [{type: 'LABEL_DETECTION',maxResults: 10,},],});if (!result.labelAnnotations) {return { labels: [] };}const labels = result.labelAnnotations.filter(label => label.score && label.score > 0.5).map(label => ({description: label.description,score: label.score,}));return { labels };};export const reduceLabelsToFilters = (labels: Label[]) => {const optionalFilters = labels.map(label => `labels.description:'${label.description}'`,);return optionalFilters;};
Conclusion
This implementation demonstrates an approach to visual product search, showcasing the power of modern web and mobile technologies in creating innovative e-commerce experiences.
Source Code: https://github.com/IliasHad/visual-product-search-app
Notes:
What I mentioned above is one of the approaches to use for product image search, we can have other methods like:
- Vision API Product Search documentation**:** https://cloud.google.com/vision/product-search/docs
- Image Similarity Search with Vector Database: https://weaviate.io/developers/weaviate/search/image
- React Native