Users expect fast, relevant search. Elasticsearch provides powerful full-text search capabilities. Here's how to implement search features that users love.
Elasticsearch Setup#
1import { Client } from '@elastic/elasticsearch';
2
3const elastic = new Client({
4 node: process.env.ELASTICSEARCH_URL || 'http://localhost:9200',
5 auth: {
6 username: process.env.ELASTICSEARCH_USER,
7 password: process.env.ELASTICSEARCH_PASSWORD,
8 },
9});
10
11// Create index with mappings
12async function createProductIndex() {
13 await elastic.indices.create({
14 index: 'products',
15 body: {
16 settings: {
17 number_of_shards: 1,
18 number_of_replicas: 1,
19 analysis: {
20 analyzer: {
21 autocomplete: {
22 type: 'custom',
23 tokenizer: 'standard',
24 filter: ['lowercase', 'autocomplete_filter'],
25 },
26 },
27 filter: {
28 autocomplete_filter: {
29 type: 'edge_ngram',
30 min_gram: 2,
31 max_gram: 20,
32 },
33 },
34 },
35 },
36 mappings: {
37 properties: {
38 name: {
39 type: 'text',
40 analyzer: 'standard',
41 fields: {
42 autocomplete: {
43 type: 'text',
44 analyzer: 'autocomplete',
45 },
46 keyword: {
47 type: 'keyword',
48 },
49 },
50 },
51 description: {
52 type: 'text',
53 },
54 category: {
55 type: 'keyword',
56 },
57 brand: {
58 type: 'keyword',
59 },
60 price: {
61 type: 'float',
62 },
63 rating: {
64 type: 'float',
65 },
66 inStock: {
67 type: 'boolean',
68 },
69 tags: {
70 type: 'keyword',
71 },
72 createdAt: {
73 type: 'date',
74 },
75 },
76 },
77 },
78 });
79}Indexing Documents#
1// Index single document
2async function indexProduct(product: Product) {
3 await elastic.index({
4 index: 'products',
5 id: product.id,
6 body: {
7 name: product.name,
8 description: product.description,
9 category: product.category,
10 brand: product.brand,
11 price: product.price,
12 rating: product.rating,
13 inStock: product.inStock,
14 tags: product.tags,
15 createdAt: product.createdAt,
16 },
17 });
18}
19
20// Bulk indexing
21async function bulkIndexProducts(products: Product[]) {
22 const operations = products.flatMap((product) => [
23 { index: { _index: 'products', _id: product.id } },
24 {
25 name: product.name,
26 description: product.description,
27 category: product.category,
28 brand: product.brand,
29 price: product.price,
30 rating: product.rating,
31 inStock: product.inStock,
32 tags: product.tags,
33 createdAt: product.createdAt,
34 },
35 ]);
36
37 const result = await elastic.bulk({ operations });
38
39 if (result.errors) {
40 const errors = result.items.filter((item) => item.index?.error);
41 logger.error('Bulk indexing errors', { errors });
42 }
43
44 return result;
45}
46
47// Keep index in sync
48async function syncProductToElastic(productId: string) {
49 const product = await prisma.product.findUnique({
50 where: { id: productId },
51 });
52
53 if (product) {
54 await indexProduct(product);
55 } else {
56 await elastic.delete({
57 index: 'products',
58 id: productId,
59 });
60 }
61}Basic Search#
1interface SearchParams {
2 query: string;
3 page?: number;
4 limit?: number;
5 sort?: string;
6 filters?: {
7 category?: string;
8 brand?: string[];
9 minPrice?: number;
10 maxPrice?: number;
11 inStock?: boolean;
12 };
13}
14
15async function searchProducts(params: SearchParams) {
16 const {
17 query,
18 page = 1,
19 limit = 20,
20 sort = 'relevance',
21 filters = {},
22 } = params;
23
24 const must: any[] = [];
25 const filter: any[] = [];
26
27 // Full-text search
28 if (query) {
29 must.push({
30 multi_match: {
31 query,
32 fields: ['name^3', 'description', 'brand^2', 'tags'],
33 type: 'best_fields',
34 fuzziness: 'AUTO',
35 },
36 });
37 }
38
39 // Filters
40 if (filters.category) {
41 filter.push({ term: { category: filters.category } });
42 }
43
44 if (filters.brand?.length) {
45 filter.push({ terms: { brand: filters.brand } });
46 }
47
48 if (filters.minPrice !== undefined || filters.maxPrice !== undefined) {
49 filter.push({
50 range: {
51 price: {
52 gte: filters.minPrice,
53 lte: filters.maxPrice,
54 },
55 },
56 });
57 }
58
59 if (filters.inStock !== undefined) {
60 filter.push({ term: { inStock: filters.inStock } });
61 }
62
63 // Sort options
64 const sortOptions: Record<string, any> = {
65 relevance: [{ _score: 'desc' }],
66 price_asc: [{ price: 'asc' }],
67 price_desc: [{ price: 'desc' }],
68 rating: [{ rating: 'desc' }],
69 newest: [{ createdAt: 'desc' }],
70 };
71
72 const result = await elastic.search({
73 index: 'products',
74 body: {
75 from: (page - 1) * limit,
76 size: limit,
77 query: {
78 bool: {
79 must: must.length ? must : [{ match_all: {} }],
80 filter,
81 },
82 },
83 sort: sortOptions[sort] || sortOptions.relevance,
84 highlight: {
85 fields: {
86 name: {},
87 description: {},
88 },
89 pre_tags: ['<mark>'],
90 post_tags: ['</mark>'],
91 },
92 },
93 });
94
95 return {
96 hits: result.hits.hits.map((hit) => ({
97 id: hit._id,
98 score: hit._score,
99 ...hit._source,
100 highlights: hit.highlight,
101 })),
102 total: (result.hits.total as any).value,
103 page,
104 limit,
105 pages: Math.ceil((result.hits.total as any).value / limit),
106 };
107}Faceted Search#
1async function searchWithFacets(params: SearchParams) {
2 const result = await elastic.search({
3 index: 'products',
4 body: {
5 // ... query from above ...
6 aggs: {
7 categories: {
8 terms: {
9 field: 'category',
10 size: 20,
11 },
12 },
13 brands: {
14 terms: {
15 field: 'brand',
16 size: 50,
17 },
18 },
19 price_ranges: {
20 range: {
21 field: 'price',
22 ranges: [
23 { key: 'under_25', to: 25 },
24 { key: '25_50', from: 25, to: 50 },
25 { key: '50_100', from: 50, to: 100 },
26 { key: '100_200', from: 100, to: 200 },
27 { key: 'over_200', from: 200 },
28 ],
29 },
30 },
31 avg_rating: {
32 avg: {
33 field: 'rating',
34 },
35 },
36 rating_distribution: {
37 histogram: {
38 field: 'rating',
39 interval: 1,
40 },
41 },
42 },
43 },
44 });
45
46 return {
47 // ... hits ...
48 facets: {
49 categories: result.aggregations?.categories.buckets,
50 brands: result.aggregations?.brands.buckets,
51 priceRanges: result.aggregations?.price_ranges.buckets,
52 avgRating: result.aggregations?.avg_rating.value,
53 ratingDistribution: result.aggregations?.rating_distribution.buckets,
54 },
55 };
56}Autocomplete#
1async function autocomplete(query: string, limit = 10) {
2 const result = await elastic.search({
3 index: 'products',
4 body: {
5 size: limit,
6 query: {
7 bool: {
8 should: [
9 {
10 match: {
11 'name.autocomplete': {
12 query,
13 operator: 'and',
14 },
15 },
16 },
17 {
18 match_phrase_prefix: {
19 name: {
20 query,
21 boost: 2,
22 },
23 },
24 },
25 ],
26 },
27 },
28 _source: ['name', 'category', 'brand'],
29 },
30 });
31
32 return result.hits.hits.map((hit) => ({
33 id: hit._id,
34 name: (hit._source as any).name,
35 category: (hit._source as any).category,
36 brand: (hit._source as any).brand,
37 }));
38}
39
40// API endpoint
41app.get('/api/search/autocomplete', async (req, res) => {
42 const { q } = req.query;
43
44 if (!q || typeof q !== 'string' || q.length < 2) {
45 return res.json([]);
46 }
47
48 const suggestions = await autocomplete(q);
49 res.json(suggestions);
50});Search Analytics#
1// Log searches for analytics
2async function logSearch(params: {
3 query: string;
4 userId?: string;
5 results: number;
6 responseTime: number;
7}) {
8 await elastic.index({
9 index: 'search_analytics',
10 body: {
11 ...params,
12 timestamp: new Date(),
13 },
14 });
15}
16
17// Popular searches
18async function getPopularSearches(days = 7, limit = 10) {
19 const result = await elastic.search({
20 index: 'search_analytics',
21 body: {
22 size: 0,
23 query: {
24 range: {
25 timestamp: {
26 gte: `now-${days}d`,
27 },
28 },
29 },
30 aggs: {
31 popular: {
32 terms: {
33 field: 'query.keyword',
34 size: limit,
35 },
36 },
37 },
38 },
39 });
40
41 return result.aggregations?.popular.buckets;
42}Best Practices#
Indexing:
✓ Use bulk operations for many documents
✓ Refresh indices strategically
✓ Keep mappings consistent
✓ Use aliases for zero-downtime reindexing
Searching:
✓ Implement proper pagination
✓ Use filters for exact matches
✓ Tune relevance with boosting
✓ Add fuzziness for typo tolerance
Performance:
✓ Cache common queries
✓ Use appropriate shard counts
✓ Monitor query performance
✓ Limit result sizes
Conclusion#
Elasticsearch enables powerful search experiences. Start with basic full-text search, add facets for filtering, and implement autocomplete for instant feedback.
Monitor search analytics to understand what users are looking for and tune relevance accordingly.