This tutorial builds a complete image search feature in React. By the end, your users will be able to type "red sneakers" and see matching product photos, or upload an image and find visually similar ones in your database.
We'll build two things: a small Express backend that talks to the Vecstore API, and a React component with a search input that supports both text queries and image uploads.
What We're Building
A React component that supports two types of search:
- Text-to-image search - user types a description like "wooden desk lamp" and gets matching images
- Reverse image search - user uploads or drags in a photo and gets visually similar images back
Both go through the same API and the same image database.
Prerequisites
- Node.js 18+
- A React project (Create React App, Vite, Next.js, whatever you use)
- A Vecstore account (free tier works)
- An image database created in the Vecstore dashboard
- Your API key and database ID from the dashboard
Step 1: Set Up the Backend
You need a thin backend to keep your API key out of the browser. A few Express routes will do.
npm install express cors multer
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_URL = 'https://api.vecstore.app/api';
// Search by text description
app.post('/api/search/text', async (req, res) => {
const { query, top_k = 12 } = req.body;
const result = await fetch(
`${BASE_URL}/databases/${DB_ID}/search`,
{
method: 'POST',
headers: {
'X-API-Key': API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, top_k }),
}
);
res.json(await result.json());
});
// Search by image upload
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_URL}/databases/${DB_ID}/search`,
{
method: 'POST',
headers: {
'X-API-Key': API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ image: base64, top_k: 12 }),
}
);
// clean up uploaded file
fs.unlinkSync(req.file.path);
res.json(await result.json());
});
// Insert an image (for populating your database)
app.post('/api/images', async (req, res) => {
const { image_url } = req.body;
const result = await fetch(
`${BASE_URL}/databases/${DB_ID}/documents`,
{
method: 'POST',
headers: {
'X-API-Key': API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ image_url }),
}
);
res.json(await result.json());
});
app.listen(3001, () => console.log('Server running on 3001'));
Three routes: text search, image search, and an insert endpoint for populating your database. Run it with VECSTORE_API_KEY=your_key VECSTORE_DB_ID=your_id node server.js.
Step 2: Populate Your Database
Before search works, you need images in your database. You can do this through the dashboard or by hitting the insert endpoint. Here's a quick script to insert a batch of image URLs:
const images = [
'https://example.com/products/sneaker-red.jpg',
'https://example.com/products/sneaker-blue.jpg',
'https://example.com/products/boot-leather.jpg',
// ... your image URLs
];
for (const url of images) {
await fetch('http://localhost:3001/api/images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_url: url }),
});
console.log(`Inserted: ${url}`);
}
Each image gets embedded automatically when you insert it. No tagging, no preprocessing.
Step 3: Build the React Component
Here's the full ImageSearch component. It handles text input, image upload via file picker, and drag-and-drop.
import { useState, useCallback } from 'react';
export default function ImageSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [preview, setPreview] = useState(null);
const [dragging, setDragging] = useState(false);
// Search by text
const searchByText = async (e) => {
e.preventDefault();
if (!query.trim()) return;
setLoading(true);
setPreview(null);
const res = await fetch('http://localhost:3001/api/search/text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
});
const data = await res.json();
setResults(data.results || []);
setLoading(false);
};
// Search by image file
const searchByImage = async (file) => {
setLoading(true);
setQuery('');
setPreview(URL.createObjectURL(file));
const formData = new FormData();
formData.append('image', file);
const res = await fetch('http://localhost:3001/api/search/image', {
method: 'POST',
body: formData,
});
const data = await res.json();
setResults(data.results || []);
setLoading(false);
};
// Drag and drop handlers
const onDrop = useCallback((e) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
searchByImage(file);
}
}, []);
return (
<div
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
>
{/* Search input */}
<form onSubmit={searchByText}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Describe what you're looking for..."
/>
<button type="submit">Search</button>
<label>
or upload an image
<input
type="file"
accept="image/*"
onChange={(e) => searchByImage(e.target.files[0])}
hidden
/>
</label>
</form>
{/* Drag overlay */}
{dragging && (
<div className="drag-overlay">
Drop an image to search
</div>
)}
{/* Query image preview */}
{preview && (
<div>
<p>Searching for images similar to:</p>
<img src={preview} alt="Query" />
</div>
)}
{/* Results grid */}
{loading ? (
<p>Searching...</p>
) : (
<div className="results-grid">
{results.map((result) => (
<div key={result.vector_id}>
<img src={result.metadata?.image_url} alt="" />
<span>{(result.score * 100).toFixed(1)}% match</span>
</div>
))}
</div>
)}
</div>
);
}
That's the whole component. Text search, image upload, drag-and-drop, results grid.
Step 4: Add Some CSS
The component works without styling, but a basic grid makes the results usable:
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
margin-top: 24px;
}
.results-grid img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 8px;
}
.drag-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
z-index: 50;
pointer-events: none;
}
How It Works Under the Hood
When you insert an image into Vecstore, it gets converted into a vector embedding automatically. When a user searches by text, that text also gets converted into a vector in the same space. The API finds the images whose vectors are closest to the query vector and returns them ranked by similarity.
This is why "red sneakers" finds photos of red sneakers even if nobody tagged them with those words. The model understands visual content, not just metadata.
Image-to-image search works the same way. The uploaded photo becomes a vector, and the API finds the most similar vectors in your database.
Things to Keep in Mind
API key security. Never call the Vecstore API directly from your React app. The API key would be visible in the browser's network tab. Always proxy through your own backend like we did above.
Image size. Large uploads will be slow over the network. Consider resizing images client-side before sending them to your backend. 1000px on the longest side is more than enough for search quality.
Result metadata. The results come back with a vector_id and whatever metadata you stored when inserting. If you need to display the original image, make sure to store the image URL as metadata when you insert, or use the image_url field which Vecstore stores automatically.
Debounce text search. If you want to add search-as-you-type, debounce the API calls. You don't want to fire a request on every keystroke.
import { useMemo } from 'react';
function useDebounce(fn, delay) {
return useMemo(() => {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}, [fn, delay]);
}
// In your component:
const debouncedSearch = useDebounce(async (text) => {
if (!text.trim()) return;
setLoading(true);
const res = await fetch('http://localhost:3001/api/search/text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: text }),
});
const data = await res.json();
setResults(data.results || []);
setLoading(false);
}, 300);
What Else You Can Do
Once your image database is set up, the same API supports a few more search types without any extra configuration:
- Face search - upload a photo of someone and find every image of that person in your database
- OCR search - find images that contain specific text (signs, screenshots, documents)
- NSFW detection - check uploaded images for content safety before displaying them
These all work through the same API key and database. No separate setup.
Wrapping Up
The full setup is: an Express backend with three routes, a React component with text input and image upload, and a Vecstore database with your images in it. No embedding models, no vector database, no GPU servers.
The code from this tutorial is a starting point. For a production app, you'd add error handling, loading states, pagination, and probably a nicer UI. But the search itself works as shown.
Get started with Vecstore - free tier includes enough credits to build and test your image search.
Top comments (0)