Full-Stack Search
Implementing Search Functionality
Explanation
Search Implementation
Search involves frontend UI, backend processing, and database queries. The right approach depends on data size and requirements.
Search Options
| Approach | Best For | Complexity | |----------|----------|------------| | Database queries | Small datasets | Low | | Full-text search | Medium datasets | Medium | | Elasticsearch | Large datasets | High | | Algolia | Quick setup | Low |
Demonstration
Example 1: Basic Database Search
// Backend: MongoDB text search
const mongoose = require('mongoose');
const productSchema = new mongoose.Schema({
name: { type: String, required: true },
description: String,
category: String,
price: Number,
tags: [String]
});
// Create text index
productSchema.index({
name: 'text',
description: 'text',
tags: 'text'
}, {
weights: {
name: 10,
tags: 5,
description: 1
}
});
const Product = mongoose.model('Product', productSchema);
// Search endpoint
router.get('/search', async (req, res) => {
const {
q,
category,
minPrice,
maxPrice,
sort = 'relevance',
page = 1,
limit = 20
} = req.query;
const query = {};
// Text search
if (q) {
query.$text = { $search: q };
}
// Filters
if (category) {
query.category = category;
}
if (minPrice || maxPrice) {
query.price = {};
if (minPrice) query.price.$gte = parseFloat(minPrice);
if (maxPrice) query.price.$lte = parseFloat(maxPrice);
}
// Sorting
let sortOption = {};
switch (sort) {
case 'price_asc':
sortOption = { price: 1 };
break;
case 'price_desc':
sortOption = { price: -1 };
break;
case 'newest':
sortOption = { createdAt: -1 };
break;
default:
if (q) {
sortOption = { score: { $meta: 'textScore' } };
}
}
const skip = (page - 1) * limit;
const [results, total] = await Promise.all([
Product.find(query)
.select(q ? { score: { $meta: 'textScore' } } : {})
.sort(sortOption)
.skip(skip)
.limit(parseInt(limit)),
Product.countDocuments(query)
]);
res.json({
data: results,
meta: {
total,
page: parseInt(page),
limit: parseInt(limit),
totalPages: Math.ceil(total / limit)
}
});
});
Example 2: PostgreSQL Full-Text Search
// Knex migration
exports.up = function(knex) {
return knex.raw(`
ALTER TABLE products ADD COLUMN search_vector tsvector;
CREATE INDEX products_search_idx ON products USING gin(search_vector);
UPDATE products SET search_vector =
setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(description, '')), 'B') ||
setweight(to_tsvector('english', coalesce(array_to_string(tags, ' '), '')), 'C');
CREATE FUNCTION products_search_trigger() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('english', coalesce(NEW.name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(NEW.description, '')), 'B') ||
setweight(to_tsvector('english', coalesce(array_to_string(NEW.tags, ' '), '')), 'C');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
CREATE TRIGGER products_search_update
BEFORE INSERT OR UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION products_search_trigger();
`);
};
// Search query
async function searchProducts(query, options = {}) {
const { category, minPrice, maxPrice, page = 1, limit = 20 } = options;
let sql = knex('products')
.select('*')
.select(knex.raw('ts_rank(search_vector, plainto_tsquery(?)) as rank', [query]))
.whereRaw('search_vector @@ plainto_tsquery(?)', [query]);
if (category) {
sql = sql.where('category', category);
}
if (minPrice) {
sql = sql.where('price', '>=', minPrice);
}
if (maxPrice) {
sql = sql.where('price', '<=', maxPrice);
}
const offset = (page - 1) * limit;
const [results, [{ count }]] = await Promise.all([
sql.clone().orderBy('rank', 'desc').offset(offset).limit(limit),
sql.clone().count('* as count')
]);
return {
data: results,
total: parseInt(count),
page,
limit
};
}
Example 3: Elasticsearch
const { Client } = require('@elastic/elasticsearch');
const client = new Client({
node: process.env.ELASTICSEARCH_URL
});
// Create index with mappings
async function createProductIndex() {
await client.indices.create({
index: 'products',
body: {
settings: {
analysis: {
analyzer: {
product_analyzer: {
type: 'custom',
tokenizer: 'standard',
filter: ['lowercase', 'asciifolding', 'snowball']
}
}
}
},
mappings: {
properties: {
name: {
type: 'text',
analyzer: 'product_analyzer',
boost: 2
},
description: {
type: 'text',
analyzer: 'product_analyzer'
},
category: {
type: 'keyword'
},
tags: {
type: 'keyword'
},
price: {
type: 'float'
},
createdAt: {
type: 'date'
},
suggest: {
type: 'completion'
}
}
}
}
});
}
// Index a product
async function indexProduct(product) {
await client.index({
index: 'products',
id: product.id,
body: {
...product,
suggest: {
input: [product.name, ...product.tags]
}
}
});
}
// Search products
async function searchProducts(query, filters = {}) {
const { category, minPrice, maxPrice, page = 1, size = 20 } = filters;
const must = [
{
multi_match: {
query,
fields: ['name^2', 'description', 'tags'],
fuzziness: 'AUTO'
}
}
];
const filter = [];
if (category) {
filter.push({ term: { category } });
}
if (minPrice || maxPrice) {
filter.push({
range: {
price: {
...(minPrice && { gte: minPrice }),
...(maxPrice && { lte: maxPrice })
}
}
});
}
const result = await client.search({
index: 'products',
body: {
query: {
bool: { must, filter }
},
highlight: {
fields: {
name: {},
description: {}
}
},
aggs: {
categories: {
terms: { field: 'category' }
},
price_ranges: {
range: {
field: 'price',
ranges: [
{ to: 50 },
{ from: 50, to: 100 },
{ from: 100, to: 500 },
{ from: 500 }
]
}
}
},
from: (page - 1) * size,
size
}
});
return {
data: result.hits.hits.map(hit => ({
...hit._source,
_score: hit._score,
_highlight: hit.highlight
})),
total: result.hits.total.value,
aggregations: result.aggregations
};
}
// Autocomplete suggestions
async function getSuggestions(prefix) {
const result = await client.search({
index: 'products',
body: {
suggest: {
product_suggest: {
prefix,
completion: {
field: 'suggest',
size: 5,
skip_duplicates: true
}
}
}
}
});
return result.suggest.product_suggest[0].options.map(opt => opt.text);
}
Example 4: Frontend Search UI
// SearchPage.jsx
function SearchPage() {
const [query, setQuery] = useState('');
const [filters, setFilters] = useState({});
const [results, setResults] = useState(null);
const [loading, setLoading] = useState(false);
const debouncedQuery = useDebounce(query, 300);
// Search on query or filter change
useEffect(() => {
if (!debouncedQuery) {
setResults(null);
return;
}
const search = async () => {
setLoading(true);
try {
const params = new URLSearchParams({
q: debouncedQuery,
...filters
});
const response = await fetch(`/api/search?${params}`);
const data = await response.json();
setResults(data);
} catch (error) {
console.error('Search failed:', error);
} finally {
setLoading(false);
}
};
search();
}, [debouncedQuery, filters]);
return (
<div className="search-page">
<SearchBar
value={query}
onChange={setQuery}
loading={loading}
/>
<div className="search-content">
<SearchFilters
filters={filters}
onChange={setFilters}
aggregations={results?.aggregations}
/>
<SearchResults
results={results?.data}
total={results?.total}
loading={loading}
/>
</div>
</div>
);
}
// SearchBar with autocomplete
function SearchBar({ value, onChange, loading }) {
const [suggestions, setSuggestions] = useState([]);
const [showSuggestions, setShowSuggestions] = useState(false);
useEffect(() => {
if (value.length < 2) {
setSuggestions([]);
return;
}
const fetchSuggestions = async () => {
const response = await fetch(`/api/search/suggest?q=${value}`);
const data = await response.json();
setSuggestions(data);
};
const timer = setTimeout(fetchSuggestions, 150);
return () => clearTimeout(timer);
}, [value]);
return (
<div className="search-bar">
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setShowSuggestions(true)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
placeholder="Search products..."
/>
{loading && <Spinner />}
{showSuggestions && suggestions.length > 0 && (
<ul className="suggestions">
{suggestions.map((suggestion, i) => (
<li
key={i}
onClick={() => {
onChange(suggestion);
setShowSuggestions(false);
}}
>
{suggestion}
</li>
))}
</ul>
)}
</div>
);
}
// Faceted filters
function SearchFilters({ filters, onChange, aggregations }) {
return (
<aside className="filters">
<FilterSection title="Category">
{aggregations?.categories?.buckets.map(bucket => (
<label key={bucket.key}>
<input
type="checkbox"
checked={filters.category === bucket.key}
onChange={(e) => onChange({
...filters,
category: e.target.checked ? bucket.key : undefined
})}
/>
{bucket.key} ({bucket.doc_count})
</label>
))}
</FilterSection>
<FilterSection title="Price">
<input
type="number"
placeholder="Min"
value={filters.minPrice || ''}
onChange={(e) => onChange({
...filters,
minPrice: e.target.value || undefined
})}
/>
<input
type="number"
placeholder="Max"
value={filters.maxPrice || ''}
onChange={(e) => onChange({
...filters,
maxPrice: e.target.value || undefined
})}
/>
</FilterSection>
</aside>
);
}
Example 5: Search Analytics
// Track search queries
async function trackSearch(userId, query, results) {
await db.searchLogs.create({
userId,
query,
resultCount: results.total,
timestamp: new Date()
});
}
// Track clicks
async function trackClick(userId, searchId, productId, position) {
await db.clickLogs.create({
userId,
searchId,
productId,
position,
timestamp: new Date()
});
}
// Popular searches
async function getPopularSearches(limit = 10) {
return db.searchLogs.aggregate([
{
$match: {
timestamp: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }
}
},
{
$group: {
_id: { $toLower: '$query' },
count: { $sum: 1 }
}
},
{ $sort: { count: -1 } },
{ $limit: limit },
{
$project: {
query: '$_id',
count: 1
}
}
]);
}
// Zero-result queries (need improvement)
async function getZeroResultQueries() {
return db.searchLogs.aggregate([
{
$match: {
resultCount: 0,
timestamp: { $gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }
}
},
{
$group: {
_id: { $toLower: '$query' },
count: { $sum: 1 }
}
},
{ $sort: { count: -1 } },
{ $limit: 50 }
]);
}
Example 6: Search with Algolia
// Backend: Index products
const algoliasearch = require('algoliasearch');
const client = algoliasearch(
process.env.ALGOLIA_APP_ID,
process.env.ALGOLIA_ADMIN_KEY
);
const index = client.initIndex('products');
// Configure index
await index.setSettings({
searchableAttributes: [
'name',
'description',
'tags'
],
attributesForFaceting: [
'category',
'filterOnly(price)'
],
customRanking: [
'desc(popularity)',
'asc(price)'
]
});
// Index products
async function syncProducts() {
const products = await Product.find();
const records = products.map(p => ({
objectID: p._id.toString(),
name: p.name,
description: p.description,
category: p.category,
tags: p.tags,
price: p.price,
image: p.image
}));
await index.saveObjects(records);
}
// Frontend: React InstantSearch
import {
InstantSearch,
SearchBox,
Hits,
RefinementList,
Pagination,
Configure
} from 'react-instantsearch-hooks-web';
import algoliasearch from 'algoliasearch/lite';
const searchClient = algoliasearch(
process.env.REACT_APP_ALGOLIA_APP_ID,
process.env.REACT_APP_ALGOLIA_SEARCH_KEY
);
function Search() {
return (
<InstantSearch searchClient={searchClient} indexName="products">
<Configure hitsPerPage={20} />
<SearchBox placeholder="Search products..." />
<div className="search-panel">
<div className="filters">
<RefinementList attribute="category" />
</div>
<div className="results">
<Hits hitComponent={ProductHit} />
<Pagination />
</div>
</div>
</InstantSearch>
);
}
function ProductHit({ hit }) {
return (
<div className="product-hit">
<img src={hit.image} alt={hit.name} />
<h3>{hit.name}</h3>
<p>${hit.price}</p>
</div>
);
}
Key Takeaways:
- Start simple with database search
- Use full-text indexing for better results
- Implement debouncing on frontend
- Track analytics to improve
- Consider managed solutions like Algolia
Imitation
Challenge 1: Build Product Search
Task: Create a search with filters, pagination, and autocomplete.
Solution
Combine Examples 1-4 above to build a complete search solution with MongoDB text search, React frontend with debouncing, and autocomplete suggestions.
Practice
Exercise 1: Fuzzy Search
Difficulty: Intermediate
Add fuzzy matching for typo tolerance.
Exercise 2: Search Synonyms
Difficulty: Advanced
Implement synonym support in search.
Summary
What you learned:
- Database search options
- Full-text search setup
- Elasticsearch basics
- Frontend search UI
- Search analytics
Next Steps:
- Read: Performance
- Practice: Add search to your app
- Explore: Algolia, Meilisearch
