DEV Community

Giorgi
Giorgi

Posted on • Originally published at vecstore.app

How to Add Image Search to a Shopify Store

Most Shopify search bars only understand keywords. A customer looking for "that kind of minimalist wooden shelf" gets a page full of irrelevant results or nothing at all. They had a clear picture in their head, but the search bar couldn't understand it.

Image search fixes this. A customer uploads a photo or screenshot and your store finds visually similar products from your catalog. No tags, no keywords, no hoping the customer describes the product the same way you did.

This tutorial walks through the full setup: syncing your Shopify product catalog into a searchable image database, building the search backend, and adding it to your storefront.

What We're Building

  1. A script that pulls all products from your Shopify store and indexes their images
  2. A backend that handles text-to-image and image-to-image search
  3. A search widget for your Shopify storefront
  4. A "similar products" section on product pages

Prerequisites

  • A Shopify store with products
  • A Shopify custom app with read_products scope
  • A Vecstore account (free tier works)
  • An image database created in the Vecstore dashboard
  • Node.js 18+

Step 1: Sync Your Shopify Catalog

First, pull your products from Shopify and insert their images into Vecstore. Create sync-catalog.js:

const SHOPIFY_STORE = 'your-store.myshopify.com';
const SHOPIFY_TOKEN = process.env.SHOPIFY_ACCESS_TOKEN;
const VECSTORE_KEY = process.env.VECSTORE_API_KEY;
const VECSTORE_DB = process.env.VECSTORE_DB_ID;

async function fetchProducts(cursor = null) {
  const query = `{
    products(first: 50${cursor ? `, after: "${cursor}"` : ''}) {
      edges {
        cursor
        node {
          id
          title
          handle
          priceRangeV2 {
            minVariantPrice { amount currencyCode }
          }
          featuredImage { url }
          images(first: 1) {
            edges {
              node { url }
            }
          }
        }
      }
      pageInfo { hasNextPage }
    }
  }`;

  const res = await fetch(
    `https://${SHOPIFY_STORE}/admin/api/2026-04/graphql.json`,
    {
      method: 'POST',
      headers: {
        'X-Shopify-Access-Token': SHOPIFY_TOKEN,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ query }),
    }
  );

  return res.json();
}

async function insertImage(imageUrl, metadata) {
  const res = await fetch(
    `https://api.vecstore.app/api/databases/${VECSTORE_DB}/documents`,
    {
      method: 'POST',
      headers: {
        'X-API-Key': VECSTORE_KEY,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ image_url: imageUrl, metadata }),
    }
  );

  return res.json();
}

async function syncAll() {
  let cursor = null;
  let count = 0;

  while (true) {
    const data = await fetchProducts(cursor);
    const edges = data.data.products.edges;

    for (const { node, cursor: c } of edges) {
      const imageUrl = node.featuredImage?.url
        || node.images.edges[0]?.node.url;

      if (!imageUrl) continue;

      await insertImage(imageUrl, {
        shopify_id: node.id,
        title: node.title,
        handle: node.handle,
        price: node.priceRangeV2.minVariantPrice.amount,
        currency: node.priceRangeV2.minVariantPrice.currencyCode,
        url: `https://${SHOPIFY_STORE}/products/${node.handle}`,
        image_url: imageUrl,
      });

      count++;
      console.log(`[${count}] ${node.title}`);
      cursor = c;
    }

    if (!data.data.products.pageInfo.hasNextPage) break;
  }

  console.log(`Done. Synced ${count} products.`);
}

syncAll();

Run it:

SHOPIFY_ACCESS_TOKEN=shpat_xxx VECSTORE_API_KEY=your_key VECSTORE_DB_ID=your_db node sync-catalog.js

This pages through your entire Shopify catalog using GraphQL cursor pagination, grabs the primary image for each product, and inserts it into Vecstore with metadata attached. The metadata is important because it comes back with every search result, so you can render product cards without a second Shopify API call.

We're inserting one image per product (the featured image). If you insert all variant images, your "similar products" results will be flooded with color variants of the same item. One image per product keeps results useful.

Step 2: Build the Search Backend

You need a small backend to sit between your storefront and the Vecstore API. This keeps your API key off the client.

Create server.js:

import express from 'express';
import cors from 'cors';
import multer from 'multer';
import fs from 'fs';

const app = express();
app.use(cors());
app.use(express.json());

const upload = multer({ dest: 'uploads/' });

const API_KEY = process.env.VECSTORE_API_KEY;
const DB_ID = process.env.VECSTORE_DB_ID;
const BASE = 'https://api.vecstore.app/api';
const HEADERS = {
  'X-API-Key': API_KEY,
  'Content-Type': 'application/json',
};

// Text search - "red leather bag", "minimalist desk lamp"
app.post('/api/search/text', async (req, res) => {
  const { query, top_k = 12 } = req.body;

  const result = await fetch(`${BASE}/databases/${DB_ID}/search`, {
    method: 'POST',
    headers: HEADERS,
    body: JSON.stringify({ query, top_k }),
  });

  res.json(await result.json());
});

// Image search - upload a photo, find similar products
app.post('/api/search/image', upload.single('image'), async (req, res) => {
  const base64 = fs.readFileSync(req.file.path, { encoding: 'base64' });

  const result = await fetch(`${BASE}/databases/${DB_ID}/search`, {
    method: 'POST',
    headers: HEADERS,
    body: JSON.stringify({ image: base64, top_k: 12 }),
  });

  fs.unlinkSync(req.file.path);
  res.json(await result.json());
});

// Similar products - given a product image URL, find lookalikes
app.post('/api/similar', async (req, res) => {
  const { image_url, exclude_handle, top_k = 6 } = req.body;

  const result = await fetch(`${BASE}/databases/${DB_ID}/search`, {
    method: 'POST',
    headers: HEADERS,
    body: JSON.stringify({ image_url, top_k: top_k + 1 }),
  });

  const data = await result.json();

  // filter out the current product
  data.results = (data.results || [])
    .filter(r => r.metadata?.handle !== exclude_handle)
    .slice(0, top_k);

  res.json(data);
});

app.listen(3001, () => console.log('Running on 3001'));

Three routes: text search for the search bar, image search for photo uploads, and a similar products endpoint that filters out the current product. The similar products endpoint takes the current product's handle so it can exclude it from results.

Step 3: Add Search to Your Storefront

Now the frontend. There are two ways to get this onto your Shopify store: a theme app extension (the proper way) or a script tag (the quick way). We'll use a script tag because it works on any Shopify theme without building a full Shopify app.

Create a JavaScript file and host it somewhere accessible (your backend, a CDN, wherever). This gets injected into your Shopify theme.

// shopify-search-widget.js

const API_BASE = 'https://your-backend.com';

function createSearchWidget() {
  const container = document.createElement('div');
  container.id = 'vs-search';
  container.innerHTML = `
    <div id="vs-overlay" style="display:none; position:fixed; inset:0;
         background:rgba(0,0,0,0.5); z-index:9999;
         display:none; align-items:center; justify-content:center;">
      <div style="background:white; border-radius:12px; padding:24px;
           width:90%; max-width:640px; max-height:80vh; overflow-y:auto;">
        <div style="display:flex; gap:8px; margin-bottom:16px;">
          <input id="vs-input" type="text"
            placeholder="Describe what you're looking for..."
            style="flex:1; padding:10px 14px; border:1px solid #ddd;
                   border-radius:8px; font-size:15px;" />
          <label style="padding:10px 16px; border:1px solid #ddd;
                 border-radius:8px; cursor:pointer; font-size:14px;">
            Upload photo
            <input id="vs-file" type="file" accept="image/*"
                   style="display:none;" />
          </label>
        </div>
        <div id="vs-results" style="display:grid;
             grid-template-columns:repeat(auto-fill,minmax(140px,1fr));
             gap:12px;"></div>
      </div>
    </div>
  `;

  document.body.appendChild(container);

  const overlay = document.getElementById('vs-overlay');
  const input = document.getElementById('vs-input');
  const fileInput = document.getElementById('vs-file');
  const resultsDiv = document.getElementById('vs-results');

  // close on overlay click
  overlay.addEventListener('click', (e) => {
    if (e.target === overlay) overlay.style.display = 'none';
  });

  // text search on Enter
  let timer;
  input.addEventListener('keyup', (e) => {
    clearTimeout(timer);
    if (e.key === 'Enter') {
      searchByText(input.value);
    }
  });

  // image search on file upload
  fileInput.addEventListener('change', (e) => {
    const file = e.target.files[0];
    if (file) searchByImage(file);
  });

  async function searchByText(query) {
    if (!query.trim()) return;
    resultsDiv.innerHTML = '<p>Searching...</p>';
    const res = await fetch(`${API_BASE}/api/search/text`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query }),
    });
    renderResults(await res.json());
  }

  async function searchByImage(file) {
    resultsDiv.innerHTML = '<p>Searching...</p>';
    const formData = new FormData();
    formData.append('image', file);
    const res = await fetch(`${API_BASE}/api/search/image`, {
      method: 'POST',
      body: formData,
    });
    renderResults(await res.json());
  }

  function renderResults(data) {
    const results = data.results || [];
    if (!results.length) {
      resultsDiv.innerHTML = '<p>No results found.</p>';
      return;
    }
    resultsDiv.innerHTML = results.map(r => `
      <a href="${r.metadata.url}" style="text-decoration:none; color:inherit;">
        <img src="${r.metadata.image_url}" alt="${r.metadata.title}"
          style="width:100%; aspect-ratio:1; object-fit:cover;
                 border-radius:8px;" />
        <p style="font-size:13px; margin:6px 0 2px;">${r.metadata.title}</p>
        <p style="font-size:13px; font-weight:600;">
          ${r.metadata.currency} ${r.metadata.price}
        </p>
      </a>
    `).join('');
  }

  return { open: () => { overlay.style.display = 'flex'; input.focus(); } };
}

// Initialize
const searchWidget = createSearchWidget();

// Hook into existing search icon/button on your theme
document.querySelectorAll('[data-vs-trigger]').forEach(el => {
  el.addEventListener('click', (e) => {
    e.preventDefault();
    searchWidget.open();
  });
});

To wire this up, add a data-vs-trigger attribute to any element in your Shopify theme that should open the search modal. Could be the existing search icon, a new button, whatever. When clicked, the modal opens with text input and photo upload.

In your Shopify theme, load the script by adding this to your theme.liquid before </body>:

<script src="https://your-backend.com/shopify-search-widget.js"></script>

Step 4: Add Similar Products to Product Pages

This is where the real money is. A "similar products" section on every product page that actually shows products that look alike, not just products from the same collection.

Add this script to your product page template (or theme.liquid if you want it everywhere):

// similar-products.js

const API_BASE = 'https://your-backend.com';

async function loadSimilarProducts() {
  const container = document.getElementById('vs-similar');
  if (!container) return;

  const imageUrl = container.dataset.image;
  const handle = container.dataset.handle;

  if (!imageUrl || !handle) return;

  const res = await fetch(`${API_BASE}/api/similar`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      image_url: imageUrl,
      exclude_handle: handle,
    }),
  });

  const data = await res.json();
  const results = data.results || [];

  if (!results.length) return;

  container.innerHTML = `
    <h3 style="margin-bottom:16px;">You might also like</h3>
    <div style="display:grid;
         grid-template-columns:repeat(auto-fill,minmax(160px,1fr));
         gap:16px;">
      ${results.map(r => `
        <a href="${r.metadata.url}"
           style="text-decoration:none; color:inherit;">
          <img src="${r.metadata.image_url}" alt="${r.metadata.title}"
            style="width:100%; aspect-ratio:1; object-fit:cover;
                   border-radius:8px;" />
          <p style="font-size:13px; margin:8px 0 2px;">
            ${r.metadata.title}
          </p>
          <p style="font-size:13px; font-weight:600;">
            ${r.metadata.currency} ${r.metadata.price}
          </p>
        </a>
      `).join('')}
    </div>
  `;
}

loadSimilarProducts();

In your Shopify product template, add a container where you want the similar products to appear:

<div id="vs-similar"
  data-image="{{ product.featured_image | image_url: width: 800 }}"
  data-handle="{{ product.handle }}">
</div>
<script src="https://your-backend.com/similar-products.js"></script>

Shopify's Liquid template passes the product image URL and handle as data attributes. The script picks them up, calls your backend, and renders the results. If no similar products are found, nothing shows up. No empty sections.

Keeping Your Catalog in Sync

Your Vecstore database needs to stay up to date when you add, update, or remove products in Shopify. Two approaches:

Webhook-based (recommended). Register Shopify webhooks for products/create, products/update, and products/delete. When a product changes, your backend inserts, updates, or removes it from Vecstore automatically.

// handle product create/update webhook
app.post('/webhooks/products', async (req, res) => {
  const product = req.body;
  const imageUrl = product.image?.src;

  if (!imageUrl) return res.sendStatus(200);

  await fetch(`${BASE}/databases/${DB_ID}/documents`, {
    method: 'POST',
    headers: HEADERS,
    body: JSON.stringify({
      image_url: imageUrl,
      metadata: {
        shopify_id: `gid://shopify/Product/${product.id}`,
        title: product.title,
        handle: product.handle,
        price: product.variants[0]?.price,
        url: `https://${SHOPIFY_STORE}/products/${product.handle}`,
        image_url: imageUrl,
      },
    }),
  });

  res.sendStatus(200);
});

Cron-based. Run the sync script from Step 1 on a schedule (daily, hourly, whatever fits your update frequency). Simpler to set up, but there's a lag between product changes and search results updating.

For most stores, webhooks are worth the extra setup. A customer shouldn't search for a product you added an hour ago and get nothing back.

Things to Keep in Mind

Cache similar products. The similar products for a given item don't change unless your catalog changes. Cache the results (even in a simple JSON file or Redis) so you're not hitting the API on every product page view. Refresh when your catalog syncs.

Theme compatibility. The script tag approach works on any Shopify theme. If you want tighter integration (custom blocks in the theme editor, settings panels), build a proper theme app extension instead. More work upfront, better experience for store owners.

Image quality. Shopify's image URLs support size parameters. Use width: 800 or similar when passing image URLs to Vecstore. Bigger doesn't improve search quality, it just slows things down.

Variants. Only index one image per product, not one per variant. If you sell a shirt in 5 colors and index all 5, searching for a blue shirt returns 4 other color variants of the same shirt. Not useful.

Costs. A store with 5,000 products and 30,000 daily product page views: the initial sync costs 5,000 credits, and each similar products query costs 1 credit. With caching, you're looking at the sync cost plus a fraction of the page views. At $1.60 per 1,000 credits, the math works out to a few dollars per month after caching.

What Else You Can Do

Same database, same API key:

  • Text search that understands meaning, not just keywords. "Cozy winter sweater" matches chunky knits even if none are tagged that way.
  • NSFW detection if you accept user-uploaded images. Check them before they appear on your site.
  • Customer photo search for stores with user-generated content. A customer uploads a photo from their home and finds similar products in your catalog.

Wrapping Up

The full setup: a catalog sync script, an Express backend with three routes, and two frontend scripts. One for search, one for similar products. No ML models, no GPU servers, no vector database to manage.

The hardest part is the initial catalog sync. After that, everything runs off a single API call per search query. And the similar products section is the kind of feature that directly moves revenue without requiring any ongoing manual work.

Get started with Vecstore - free tier includes enough credits to sync a small catalog and test search.

Top comments (0)