+1 (726) 227-3745

Search That Actually Finds Things: MongoDB Atlas Search, Autocomplete and Facets in a MEAN App

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: false is 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. name is indexed both as a searchable string and as an autocomplete field, so the same field serves the suggestion dropdown and the results list.
  • Facet fields are separate types. stringFacet and numberFacet support $searchMeta counts. If you also want to filter on brand with equals, add a plain token type 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:

  • $search and $searchMeta must be the first stage of the pipeline. You cannot $match first and then search the subset. Push every predicate into the compound.filter array.
  • Deep pagination is expensive. $skip past a few thousand documents gets slow. Cap page (we cap at 50 above) and offer searchAfter-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:

  1. 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.
  2. Script it. A node --test suite 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.
  3. Change one knob at a time. Field boosts (score: { boost: { value: n } }), function scores for recency or popularity, phrase with a slop for 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 $text index. 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 on name. 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. edgeGram with maxGrams: 15 stores a lot of tokens. Lower maxGrams, or use nGram only where you genuinely need mid-word matching.
  • Cache aggressively at the edge. Suggestion responses are highly repetitive; 30–60 seconds of Cache-Control removes 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.