WooCommerce runs on WordPress, which means your search is whatever WordPress gives you: keyword matching on titles and descriptions. If a customer searches "minimalist oak shelf" and your product is titled "Wall-Mounted Wooden Display Unit," they get nothing. Same product, different words, no result.
Image search fixes this. Customers upload a photo or type a natural description and your store returns visually similar products. This tutorial walks through the full setup: a WordPress plugin that syncs your catalog, a search widget for the storefront, and a "similar products" section on every product page.
What We're Building
- A WordPress plugin that syncs your WooCommerce products to Vecstore
- Text-to-image and image-to-image search on your storefront
- A "similar products" section on every product page
- Auto-sync when products are created, updated, or deleted
Prerequisites
- A WooCommerce store
- FTP or file access to your WordPress install
- A Vecstore account (free tier works)
- An image database created in the Vecstore dashboard
- Your Vecstore API key and database ID
Step 1: Create the Plugin
Create a folder at wp-content/plugins/vecstore-search/. Inside, create vecstore-search.php:
<?php
/**
* Plugin Name: Vecstore Image Search for WooCommerce
* Description: Visual search and similar products for WooCommerce.
* Version: 1.0.0
*/
if (!defined('ABSPATH')) exit;
define('VECSTORE_API_BASE', 'https://api.vecstore.app/api');
define('VECSTORE_API_KEY', get_option('vecstore_api_key'));
define('VECSTORE_DB_ID', get_option('vecstore_db_id'));
// Load sub-files
require_once __DIR__ . '/includes/settings.php';
require_once __DIR__ . '/includes/sync.php';
require_once __DIR__ . '/includes/api.php';
require_once __DIR__ . '/includes/frontend.php';
Now the settings page so store owners can enter their API key. Create includes/settings.php:
<?php
add_action('admin_menu', function() {
add_options_page(
'Vecstore Search',
'Vecstore Search',
'manage_options',
'vecstore-search',
'vecstore_settings_page'
);
});
function vecstore_settings_page() {
if (isset($_POST['submit'])) {
update_option('vecstore_api_key',
sanitize_text_field($_POST['api_key']));
update_option('vecstore_db_id',
sanitize_text_field($_POST['db_id']));
echo '<div class="notice notice-success"><p>Saved</p></div>';
}
$api_key = get_option('vecstore_api_key', '');
$db_id = get_option('vecstore_db_id', '');
?>
<div class="wrap">
<h1>Vecstore Search Settings</h1>
<form method="post">
<table class="form-table">
<tr>
<th>API Key</th>
<td><input type="text" name="api_key"
value="<?php echo esc_attr($api_key); ?>"
class="regular-text" /></td>
</tr>
<tr>
<th>Database ID</th>
<td><input type="text" name="db_id"
value="<?php echo esc_attr($db_id); ?>"
class="regular-text" /></td>
</tr>
</table>
<p>
<button type="submit" name="submit"
class="button button-primary">Save</button>
<button type="button" id="vecstore-sync"
class="button">Sync Catalog Now</button>
</p>
</form>
<div id="vecstore-sync-status"></div>
</div>
<script>
document.getElementById('vecstore-sync').addEventListener('click', async () => {
const status = document.getElementById('vecstore-sync-status');
status.innerHTML = 'Syncing...';
const res = await fetch('<?php echo admin_url('admin-ajax.php'); ?>', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'action=vecstore_sync_all',
});
const data = await res.json();
status.innerHTML = data.success
? `Synced ${data.data.count} products.`
: 'Sync failed.';
});
</script>
<?php
}
This adds a settings page under Settings > Vecstore Search with fields for API key and database ID, plus a "Sync Catalog Now" button.
Step 2: Sync the Catalog
Create includes/sync.php. This handles inserting products into Vecstore.
<?php
function vecstore_insert_product($product_id) {
$product = wc_get_product($product_id);
if (!$product || $product->get_status() !== 'publish') return;
$image_id = $product->get_image_id();
if (!$image_id) return;
$image_url = wp_get_attachment_image_url($image_id, 'large');
if (!$image_url) return;
$metadata = [
'product_id' => $product_id,
'title' => $product->get_name(),
'price' => $product->get_price(),
'url' => get_permalink($product_id),
'image_url' => $image_url,
'in_stock' => $product->is_in_stock(),
];
$response = wp_remote_post(
VECSTORE_API_BASE . '/databases/' . VECSTORE_DB_ID . '/documents',
[
'headers' => [
'X-API-Key' => VECSTORE_API_KEY,
'Content-Type' => 'application/json',
],
'body' => json_encode([
'image_url' => $image_url,
'metadata' => $metadata,
]),
'timeout' => 15,
]
);
if (!is_wp_error($response)) {
$data = json_decode(wp_remote_retrieve_body($response), true);
if (!empty($data['document_id'])) {
update_post_meta($product_id,
'_vecstore_doc_id', $data['document_id']);
}
}
}
function vecstore_delete_product($product_id) {
$doc_id = get_post_meta($product_id, '_vecstore_doc_id', true);
if (!$doc_id) return;
wp_remote_request(
VECSTORE_API_BASE . '/databases/' . VECSTORE_DB_ID
. '/documents/' . $doc_id,
[
'method' => 'DELETE',
'headers' => ['X-API-Key' => VECSTORE_API_KEY],
]
);
delete_post_meta($product_id, '_vecstore_doc_id');
}
// Auto-sync on product save/delete
add_action('woocommerce_update_product', 'vecstore_insert_product');
add_action('woocommerce_new_product', 'vecstore_insert_product');
add_action('before_delete_post', function($post_id) {
if (get_post_type($post_id) === 'product') {
vecstore_delete_product($post_id);
}
});
// Bulk sync (triggered from settings page)
add_action('wp_ajax_vecstore_sync_all', function() {
$products = wc_get_products([
'status' => 'publish',
'limit' => -1,
'return' => 'ids',
]);
$count = 0;
foreach ($products as $product_id) {
vecstore_insert_product($product_id);
$count++;
}
wp_send_json_success(['count' => $count]);
});
Three things happening:
vecstore_insert_productpushes one product to Vecstore with all metadata attached- Hooks on
woocommerce_update_productandwoocommerce_new_productauto-sync changes - An AJAX endpoint
vecstore_sync_allhandles bulk sync from the settings page
The document ID from Vecstore gets saved as post meta so we can delete it cleanly when the product is removed.
For stores with thousands of products, the bulk sync will hit PHP's max execution time. Use WP-CLI instead:
wp eval 'foreach (wc_get_products(["limit" => -1, "return" => "ids"]) as $id) { vecstore_insert_product($id); echo "$id\n"; }'
Step 3: Build the Search API
WordPress REST API endpoints that the frontend will call. Create includes/api.php:
<?php
add_action('rest_api_init', function() {
register_rest_route('vecstore/v1', '/search/text', [
'methods' => 'POST',
'callback' => 'vecstore_search_text',
'permission_callback' => '__return_true',
]);
register_rest_route('vecstore/v1', '/search/image', [
'methods' => 'POST',
'callback' => 'vecstore_search_image',
'permission_callback' => '__return_true',
]);
register_rest_route('vecstore/v1', '/similar', [
'methods' => 'POST',
'callback' => 'vecstore_similar',
'permission_callback' => '__return_true',
]);
});
function vecstore_call_api($body) {
$response = wp_remote_post(
VECSTORE_API_BASE . '/databases/' . VECSTORE_DB_ID . '/search',
[
'headers' => [
'X-API-Key' => VECSTORE_API_KEY,
'Content-Type' => 'application/json',
],
'body' => json_encode($body),
'timeout' => 10,
]
);
if (is_wp_error($response)) return ['results' => []];
return json_decode(wp_remote_retrieve_body($response), true);
}
function vecstore_search_text($request) {
$query = sanitize_text_field($request->get_param('query'));
return vecstore_call_api(['query' => $query, 'top_k' => 12]);
}
function vecstore_search_image($request) {
$files = $request->get_file_params();
if (empty($files['image'])) return ['results' => []];
$image_data = base64_encode(
file_get_contents($files['image']['tmp_name'])
);
return vecstore_call_api(['image' => $image_data, 'top_k' => 12]);
}
function vecstore_similar($request) {
$image_url = esc_url_raw($request->get_param('image_url'));
$exclude_id = intval($request->get_param('exclude_id'));
$data = vecstore_call_api([
'image_url' => $image_url,
'top_k' => 7,
]);
// filter out current product
$results = array_filter(
$data['results'] ?? [],
fn($r) => intval($r['metadata']['product_id'] ?? 0) !== $exclude_id
);
return ['results' => array_values(array_slice($results, 0, 6))];
}
Three endpoints: /wp-json/vecstore/v1/search/text, /search/image, and /similar. The Vecstore API key stays server-side. The frontend never sees it.
Step 4: Add the Frontend
Create includes/frontend.php. This injects the search widget and similar products section into your theme.
<?php
// Add search widget script to all pages
add_action('wp_footer', function() {
$api_base = rest_url('vecstore/v1');
?>
<div id="vs-overlay" style="display:none; position:fixed; inset:0;
background:rgba(0,0,0,0.6); z-index:99999;
align-items:center; justify-content:center;">
<div style="background:white; border-radius:12px; padding:24px;
width:90%; max-width:720px; max-height:80vh; overflow-y:auto;">
<div style="display:flex; gap:8px; margin-bottom:16px;">
<input id="vs-input" type="text"
placeholder="Describe or upload a photo..."
style="flex:1; padding:10px 14px; border:1px solid #ddd;
border-radius:8px;" />
<label style="padding:10px 16px; border:1px solid #ddd;
border-radius:8px; cursor:pointer;">
Upload
<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(150px,1fr));
gap:12px;"></div>
</div>
</div>
<script>
(function() {
const API = '<?php echo esc_js($api_base); ?>';
const overlay = document.getElementById('vs-overlay');
const input = document.getElementById('vs-input');
const file = document.getElementById('vs-file');
const results = document.getElementById('vs-results');
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.style.display = 'none';
});
input.addEventListener('keyup', (e) => {
if (e.key === 'Enter') searchText(input.value);
});
file.addEventListener('change', (e) => {
if (e.target.files[0]) searchImage(e.target.files[0]);
});
async function searchText(q) {
if (!q.trim()) return;
results.innerHTML = '<p>Searching...</p>';
const r = await fetch(API + '/search/text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: q }),
});
render(await r.json());
}
async function searchImage(f) {
results.innerHTML = '<p>Searching...</p>';
const fd = new FormData();
fd.append('image', f);
const r = await fetch(API + '/search/image', {
method: 'POST',
body: fd,
});
render(await r.json());
}
function render(data) {
const items = data.results || [];
if (!items.length) {
results.innerHTML = '<p>No results.</p>';
return;
}
results.innerHTML = items.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.price}</p>
</a>
`).join('');
}
// hook into any element with data-vs-trigger
document.querySelectorAll('[data-vs-trigger]').forEach(el => {
el.addEventListener('click', (e) => {
e.preventDefault();
overlay.style.display = 'flex';
input.focus();
});
});
})();
</script>
<?php
});
Add data-vs-trigger to any button or link in your theme to open the search modal. Existing search icon works, or add a new button.
Step 5: Similar Products on Product Pages
Add this to the same includes/frontend.php:
// Similar products section on single product page
add_action('woocommerce_after_single_product_summary', function() {
global $product;
if (!$product) return;
$image_url = wp_get_attachment_image_url(
$product->get_image_id(), 'large'
);
if (!$image_url) return;
$api_base = rest_url('vecstore/v1');
?>
<section id="vs-similar"
data-image="<?php echo esc_attr($image_url); ?>"
data-product-id="<?php echo esc_attr($product->get_id()); ?>"
style="margin-top:40px;">
</section>
<script>
(async function() {
const container = document.getElementById('vs-similar');
const res = await fetch('<?php echo esc_js($api_base); ?>/similar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_url: container.dataset.image,
exclude_id: parseInt(container.dataset.productId),
}),
});
const data = await res.json();
if (!data.results?.length) return;
container.innerHTML = `
<h3>You might also like</h3>
<div style="display:grid;
grid-template-columns:repeat(auto-fill,minmax(160px,1fr));
gap:16px; margin-top:16px;">
${data.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.price}</p>
</a>
`).join('')}
</div>
`;
})();
</script>
<?php
});
The woocommerce_after_single_product_summary hook fires right below the main product area on single product pages. No template overrides needed. The similar products section only renders if there are actually similar products to show.
Activate the Plugin
- Zip the
vecstore-searchfolder or upload it directly towp-content/plugins/ - In WordPress admin, go to Plugins and activate "Vecstore Image Search for WooCommerce"
- Go to Settings > Vecstore Search, enter your API key and database ID
- Click "Sync Catalog Now"
Your products get pushed to Vecstore. After that, any product changes sync automatically.
Things to Keep in Mind
Bulk sync performance. For stores with more than a few hundred products, the browser-triggered sync will time out. Use WP-CLI instead, or break the sync into chunks by post ID range.
Image size. wp_get_attachment_image_url($id, 'large') grabs WordPress's "large" size (1024px by default). That's plenty for embedding quality. Don't use "full" unless your source images are already small.
Variants. WooCommerce variable products have variations with their own images. This plugin indexes only the parent product's featured image. If you index every variation, customers searching for a blue shirt will see the same shirt in 4 other colors as "similar." Keep it to one image per product.
Caching. If you have a page cache plugin (WP Rocket, W3 Total Cache), the similar products section will be cached along with the page. That's usually fine since the results don't change often. But if you update your catalog, purge the cache.
Rate limiting. The search endpoints are open to anyone who visits your site. Add rate limiting if abuse is a concern. A simple transient-based counter keyed to IP works for most stores:
$ip = $_SERVER['REMOTE_ADDR'];
$key = 'vs_rate_' . md5($ip);
$count = (int) get_transient($key);
if ($count > 30) return ['results' => [], 'error' => 'rate_limited'];
set_transient($key, $count + 1, 60);
Hook position. woocommerce_after_single_product_summary fires below the product area. If you want the similar products section in a different spot, check Business Bloomer's visual hook guide for every available hook.
What Else You Can Do
Same database, same API key:
- Out-of-stock alternatives - filter similar products by
in_stock: trueso customers never hit a dead-end - NSFW detection for stores that accept user-uploaded images (reviews with photos, custom orders)
- OCR search to let customers search your catalog by text printed on product images (signs, labels, packaging)
Wrapping Up
Full setup: one WordPress plugin with a settings page, three WP REST endpoints, a search widget, and a similar products section. No ML models, no GPU, no vector database.
The plugin auto-syncs on product updates so your search index stays current without manual work. Once activated and synced, the only ongoing cost is the API calls for searches and similar products queries.
Get started with Vecstore - free tier covers syncing a small catalog and testing search.


