Every client app eventually grows a search box, and the first version is almost always the same: a $regex query against a title field, fired on every keystroke. It works with 500 documents and falls over with 500,000 — no relevance ranking, no typo tolerance, no facets, and a collection scan on every character typed.
This tutorial replaces that with a real search feature in a MEAN app: MongoDB Atlas Search for full-text relevance and autocomplete, an Express 5 endpoint that returns results and facet counts in one round trip, and an Angular 22 signals front end that debounces input and cancels in-flight requests. We also cover what to do if you self-host MongoDB and cannot use Atlas Search. Versions are current as of 2026: MongoDB 8.0, Mongoose 8, Express 5, Node 24 LTS, Angular 22.
This is full-text search, not semantic search. If you want meaning-based retrieval for an AI feature, see our companion tutorial on MongoDB Atlas Vector Search and RAG — and read to the end here, where the two are combined into hybrid search.
The data model
Assume a product catalogue:
import { Schema, model } from 'mongoose';
const productSchema = new Schema({
name: { type: String, required: true, trim: true },
description: { type: String, default: '' },
brand: { type: String, index: true },
category: { type: String, index: true },
tags: [String],
price: { type: Number, required: true, min: 0 },
inStock: { type: Boolean, default: true },
}, { timestamps: true });
export const Product = model('Product', productSchema);
Step 1: define the Atlas Search index
Atlas Search indexes are separate from regular MongoDB indexes; they are Lucene indexes maintained by mongot alongside your collection and updated from the change stream. Create one in the Atlas UI (Search → Create Search Index → JSON editor), or with the Atlas CLI, or — new since driver 6.6 — from code.
Index definition, named products_search:
{
"mappings": {
"dynamic": false,
"fields": {
"name": [
{ "type": "string", "analyzer": "lucene.standard" },
{ "type": "autocomplete", "tokenization": "edgeGram", "minGrams": 2, "maxGrams": 15, "foldDiacritics": true }
],
"description": { "type": "string" },
"tags": { "type": "string" },
"brand": { "type": "stringFacet" },
"category": { "type": "stringFacet" },
"price": { "type": "numberFacet", "representation": "double" },
"inStock": { "type": "boolean" }
}
}
}
Three things worth knowing before you copy this:
dynamic: falseis deliberate. Dynamic mapping indexes every field and inflates index size and build time. Name the fields you actually search.- A field can have two types.
nameis indexed both as a searchable string and as anautocompletefield, so the same field serves the suggestion dropdown and the results list. - Facet fields are separate types.
stringFacetandnumberFacetsupport$searchMetacounts. If you also want to filter onbrandwithequals, add a plaintokentype mapping for it.
Creating the index programmatically, which belongs in your migration scripts rather than a click path:
// scripts/create-search-index.js
import mongoose from 'mongoose';
import definition from './products_search.json' with { type: 'json' };
await mongoose.connect(process.env.MONGODB_URI);
const coll = mongoose.connection.db.collection('products');
const existing = await coll.listSearchIndexes('products_search').toArray();
if (existing.length === 0) {
await coll.createSearchIndex({ name: 'products_search', definition });
console.log('search index queued; initial build is asynchronous');
} else {
await coll.updateSearchIndex('products_search', definition);
console.log('search index definition updated');
}
await mongoose.disconnect();
Index builds are asynchronous. listSearchIndexes reports a status of PENDING, BUILDING, then READY; a deploy script that assumes the index is queryable the instant createSearchIndex resolves will fail in CI.
Step 2: the autocomplete endpoint
Suggestions need to be fast and small. Return at most ten names, no descriptions, no prices.
// src/routes/search.js
import { Router } from 'express';
import { z } from 'zod';
import { Product } from '../models.js';
export const searchRouter = Router();
const suggestQuery = z.object({
q: z.string().trim().min(2).max(60),
});
searchRouter.get('/suggest', async (req, res) => {
const { q } = suggestQuery.parse(req.query);
const results = await Product.aggregate([
{
$search: {
index: 'products_search',
autocomplete: { query: q, path: 'name', fuzzy: { maxEdits: 1, prefixLength: 2 } },
highlight: { path: 'name' },
},
},
{ $limit: 10 },
{ $project: { _id: 1, name: 1, score: { $meta: 'searchScore' }, highlights: { $meta: 'searchHighlights' } } },
]);
res.set('Cache-Control', 'public, max-age=30');
res.json({ suggestions: results });
});
fuzzy.maxEdits: 1 buys you one typo. prefixLength: 2 says the first two characters must match exactly, which keeps the fuzzy expansion cheap — without it, short queries match almost everything and latency climbs.
Because Express 5 forwards rejected promises to the error middleware automatically, a bad q surfaces as a Zod error in one place:
app.use((err, _req, res, _next) => {
if (err instanceof z.ZodError) return res.status(400).json({ error: 'invalid_query', issues: err.issues });
console.error(err);
res.status(500).json({ error: 'internal' });
});
Step 3: results with filters, facets and pagination
The results endpoint is where compound earns its keep. must drives relevance, filter narrows without affecting score, and $searchMeta with facet returns the counts for the sidebar.
const searchQuery = z.object({
q: z.string().trim().max(120).default(''),
brand: z.string().trim().optional(),
category: z.string().trim().optional(),
minPrice: z.coerce.number().min(0).optional(),
maxPrice: z.coerce.number().min(0).optional(),
page: z.coerce.number().int().min(1).max(50).default(1),
limit: z.coerce.number().int().min(1).max(50).default(20),
});
function buildCompound({ q, brand, category, minPrice, maxPrice }) {
const must = q
? [{
text: {
query: q,
path: ['name', 'description', 'tags'],
fuzzy: { maxEdits: 1, prefixLength: 3 },
score: { boost: { value: 1 } },
},
}]
: [];
// Boost matches in the product name over matches buried in the description.
const should = q ? [{ text: { query: q, path: 'name', score: { boost: { value: 3 } } } }] : [];
const filter = [{ equals: { path: 'inStock', value: true } }];
if (brand) filter.push({ text: { query: brand, path: 'brand' } });
if (category) filter.push({ text: { query: category, path: 'category' } });
if (minPrice !== undefined || maxPrice !== undefined) {
filter.push({ range: { path: 'price', gte: minPrice ?? 0, lte: maxPrice ?? Number.MAX_SAFE_INTEGER } });
}
return { must, should, filter };
}
searchRouter.get('/', async (req, res) => {
const params = searchQuery.parse(req.query);
const compound = buildCompound(params);
const skip = (params.page - 1) * params.limit;
const searchStage = params.q || compound.filter.length > 1
? { $search: { index: 'products_search', compound } }
: null;
const pipeline = [
searchStage ?? { $match: { inStock: true } },
{
$facet: {
items: [
{ $skip: skip },
{ $limit: params.limit },
{ $project: { name: 1, brand: 1, category: 1, price: 1, score: { $meta: 'searchScore' } } },
],
total: [{ $count: 'value' }],
},
},
];
const [{ items, total }] = await Product.aggregate(pipeline);
res.json({
items,
page: params.page,
limit: params.limit,
total: total[0]?.value ?? 0,
});
});
Facet counts come from a separate $searchMeta call. Run it in parallel, and only when the user has not yet drilled all the way down:
searchRouter.get('/facets', async (req, res) => {
const params = searchQuery.parse(req.query);
const { must, should, filter } = buildCompound(params);
const [meta] = await Product.aggregate([
{
$searchMeta: {
index: 'products_search',
facet: {
operator: { compound: { must, should, filter } },
facets: {
brands: { type: 'string', path: 'brand', numBuckets: 10 },
categories: { type: 'string', path: 'category', numBuckets: 10 },
priceBands: { type: 'number', path: 'price', boundaries: [0, 25, 50, 100, 250, 1000], default: 'other' },
},
},
},
},
]);
res.set('Cache-Control', 'public, max-age=60');
res.json(meta?.facet ?? {});
});
Two traps we see in client code regularly:
$searchand$searchMetamust be the first stage of the pipeline. You cannot$matchfirst and then search the subset. Push every predicate into thecompound.filterarray.- Deep pagination is expensive.
$skippast a few thousand documents gets slow. Cappage(we cap at 50 above) and offersearchAfter-style cursor pagination with$search: { ..., searchAfter: token }for infinite scroll.
Step 4: the Angular 22 front end
Signals plus httpResource make the debounce-and-cancel dance short. The key detail is that typing must not fire a request per keystroke and a slow response must never overwrite a newer one.
import { Component, computed, signal } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs';
type SearchResponse = { items: Product[]; total: number; page: number; limit: number };
@Component({
selector: 'app-product-search',
template: `
<input
type="search"
[value]="rawQuery()"
(input)="onInput($any($event.target).value)"
placeholder="Search products"
aria-label="Search products" />
@if (results.isLoading()) {
<p role="status">Searching…</p>
} @else if (results.error()) {
<p role="alert">Search is temporarily unavailable.</p>
} @else {
<p role="status">{{ results.value()?.total ?? 0 }} results</p>
<ul>
@for (p of results.value()?.items ?? []; track p._id) {
<li>{{ p.name }} — {{ p.price | currency }}</li>
} @empty {
<li>No matches. Try a shorter query.</li>
}
</ul>
}
`,
})
export class ProductSearchComponent {
readonly rawQuery = signal('');
private readonly typed$ = new Subject<string>();
private readonly debouncedQuery = toSignal(
this.typed$.pipe(debounceTime(250), distinctUntilChanged()),
{ initialValue: '' },
);
readonly page = signal(1);
readonly brand = signal<string | null>(null);
readonly results = httpResource<SearchResponse>(() => {
const q = this.debouncedQuery();
return {
url: '/api/search',
params: {
q,
page: this.page(),
...(this.brand() ? { brand: this.brand()! } : {}),
},
};
});
onInput(value: string) {
this.rawQuery.set(value);
this.page.set(1);
this.typed$.next(value);
}
}
httpResource re-runs whenever a signal it reads changes and aborts the previous request, so out-of-order responses are handled for you. The 250 ms debounce is the sweet spot we keep landing on: 100 ms triples request volume for no perceived gain, 500 ms feels laggy.
Accessibility matters here and is easy to get wrong: the result count lives in an element with role="status" so screen readers announce it, and a suggestion dropdown should be a proper role="listbox" with aria-activedescendant. Our WCAG 2.2 in Angular tutorial has the full combobox pattern.
Step 5: relevance tuning you can defend
"The results feel wrong" is a bug report you will receive. Make it measurable before you touch boosts:
- Build a judgement list. Twenty to fifty real queries from your logs, each with the two or three documents a human says should be top of the list.
- Script it. A
node --testsuite that runs each query against a seeded index and asserts the expected document appears in the top three. Relevance regressions then fail CI like any other test. - Change one knob at a time. Field boosts (
score: { boost: { value: n } }),functionscores for recency or popularity,phrasewith aslopfor multi-word queries, synonym mappings for your domain vocabulary.
A synonym collection is usually the highest-value change for the least effort — it is what makes "laptop" find "notebook":
// Documents in the `synonyms` collection
{ mappingType: 'equivalent', synonyms: ['laptop', 'notebook', 'ultrabook'] }
Reference it from the index definition with "synonyms": [{ "name": "catalogue", "analyzer": "lucene.standard", "source": { "collection": "synonyms" } }], then add synonyms: 'catalogue' to the text operator.
If you cannot use Atlas Search
Self-managed MongoDB and on-premise deployments do not have $search. The honest options, in the order we recommend them:
- A
$textindex.db.products.createIndex({ name: 'text', description: 'text' }), then{ $match: { $text: { $search: q } } }sorted by{ score: { $meta: 'textScore' } }. No autocomplete, no facets, no fuzziness, one text index per collection — but zero extra infrastructure and far better than$regex. - A prefix-anchored regex for autocomplete only.
{ name: { $regex: '^' + escapeRegex(q), $options: 'i' } }can use a normal index onname. Never use an unanchored regex on a large collection. - A dedicated search engine. OpenSearch, Elasticsearch, Typesense or Meilisearch fed by a MongoDB change stream. This is the right answer above roughly a million documents or when you need per-user relevance, but you have now signed up for a second datastore, a sync pipeline and its failure modes. Our change streams tutorial covers the plumbing to keep the mirror current.
Hybrid search: full-text plus vectors
The 2026 pattern for a catalogue with an AI assistant bolted on is hybrid: run a $search and a $vectorSearch and blend the rankings with reciprocal rank fusion via $rankFusion (MongoDB 8.1+), or by hand with $unionWith and a scored $group.
const pipeline = [
{
$rankFusion: {
input: {
pipelines: {
text: [
{ $search: { index: 'products_search', text: { query: q, path: ['name', 'description'] } } },
{ $limit: 50 },
],
semantic: [
{ $vectorSearch: { index: 'products_vector', path: 'embedding', queryVector, numCandidates: 200, limit: 50 } },
],
},
},
combination: { weights: { text: 0.6, semantic: 0.4 } },
},
},
{ $limit: 20 },
];
Keyword search wins on exact SKUs, names and part numbers; vector search wins on "something warm for hiking". Fusing them is almost always better than picking one, and the weights are a product decision, not an engineering one — tune them against the same judgement list from step 5.
Performance and cost notes
- Watch the search node tier. Atlas Search can run on dedicated search nodes; on shared infrastructure a heavy index build competes with your workload. Size the index, not just the cluster.
- Autocomplete indexes are big.
edgeGramwithmaxGrams: 15stores a lot of tokens. LowermaxGrams, or usenGramonly where you genuinely need mid-word matching. - Cache aggressively at the edge. Suggestion responses are highly repetitive; 30–60 seconds of
Cache-Controlremoves a surprising share of traffic. - Instrument it. Log query text, result count and latency for every search. Zero-result queries are the cheapest product research you will ever get. If you already run OpenTelemetry, add the query as a span attribute — see tracing a slow MEAN app.
- Load test before launch. Search endpoints have a very different profile from CRUD; our k6 capacity planning tutorial has the harness.
Wrap-up
A credible search feature in a MEAN app is four moving parts: an Atlas Search index you define in code and version like a migration, an Express 5 layer that splits suggestions, results and facets into separate cacheable endpoints, an Angular 22 client that debounces and cancels, and a relevance test suite so tuning is evidence-based rather than vibes.
If search in your application is still a $regex and the complaints are piling up, that is a well-bounded piece of work. Our performance tuning and MEAN Stack API development teams do this kind of retrofit regularly — get in touch with your collection sizes and a few of the queries that return the wrong thing.