Elasticsearch with Node.js and TypeScript — Full-Text Search Guide 2026

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Introduction

Why Elasticsearch for Node.js Backends

Elasticsearch delivers sub-100ms full-text search on billions of documents. It handles relevance scoring, fuzzy matching, autocomplete, and log analytics out of the box — tasks that would cripple a relational database. The official @elastic/elasticsearch client ships TypeScript definitions, giving you compile-time safety on complex query DSL objects.

Use Elasticsearch alongside your primary database: write to Postgres or MongoDB, index into Elasticsearch, and query via search.

Installation and Connection

npm install @elastic/elasticsearch
import { Client } from '@elastic/elasticsearch';
 
const client = new Client({
  node: process.env.ELASTICSEARCH_URL ?? 'http://localhost:9200',
  auth: {
    username: process.env.ES_USER ?? 'elastic',
    password: process.env.ES_PASSWORD ?? '',
  },
});
 
// Health check at startup
const health = await client.cluster.health({});
console.log('Cluster status:', health.status); // green | yellow | red

Creating an Index with Mappings

await client.indices.create({
  index: 'products',
  mappings: {
    properties: {
      name:        { type: 'text', analyzer: 'english' },
      description: { type: 'text', analyzer: 'english' },
      price:       { type: 'float' },
      category:    { type: 'keyword' },  // exact match, not analyzed
      inStock:     { type: 'boolean' },
      createdAt:   { type: 'date' },
      tags:        { type: 'keyword' },
    },
  },
  settings: {
    number_of_shards:   1,
    number_of_replicas: 1,
  },
});

Indexing Documents

// Single document
await client.index({
  index: 'products',
  id: 'prod-001',
  document: {
    name: 'TypeScript Handbook',
    description: 'Complete guide to TypeScript for backend developers',
    price: 29.99,
    category: 'books',
    inStock: true,
    createdAt: new Date(),
    tags: ['typescript', 'programming'],
  },
});
 
// Bulk indexing for high throughput
const body = products.flatMap(p => [
  { index: { _index: 'products', _id: p.id } },
  p,
]);
 
await client.bulk({ operations: body });
await client.indices.refresh({ index: 'products' });

Search Queries

// Basic match query
const simple = await client.search({
  index: 'products',
  query: { match: { name: 'typescript handbook' } },
});
 
// Multi-field search with boosting
const multiField = await client.search({
  index: 'products',
  query: {
    multi_match: {
      query: 'typescript backend',
      fields: ['name^3', 'description', 'tags^2'],  // name has 3x boost
      fuzziness: 'AUTO',
    },
  },
});
 
// Boolean query — combine must, should, filter
const advanced = await client.search({
  index: 'products',
  query: {
    bool: {
      must:   [{ match: { description: 'typescript' } }],
      filter: [
        { term:  { category: 'books' } },
        { term:  { inStock: true } },
        { range: { price: { gte: 10, lte: 50 } } },
      ],
      should: [{ term: { tags: 'programming' } }],
      minimum_should_match: 0,
    },
  },
  from: 0,
  size: 20,
  sort: [{ price: 'asc' }],
});
 
// Extract hits
const hits = advanced.hits.hits.map(h => h._source);

Aggregations

// Category breakdown + price stats
const aggs = await client.search({
  index: 'products',
  size: 0,  // no documents, only aggregations
  aggs: {
    by_category: {
      terms: { field: 'category', size: 10 },
      aggs: {
        avg_price: { avg: { field: 'price' } },
      },
    },
    price_histogram: {
      histogram: { field: 'price', interval: 10 },
    },
  },
});

Custom Analyzers

await client.indices.create({
  index: 'articles',
  settings: {
    analysis: {
      filter: {
        autocomplete_filter: {
          type: 'edge_ngram',
          min_gram: 2,
          max_gram: 20,
        },
      },
      analyzer: {
        autocomplete: {
          type: 'custom',
          tokenizer: 'standard',
          filter: ['lowercase', 'autocomplete_filter'],
        },
      },
    },
  },
  mappings: {
    properties: {
      title: {
        type: 'text',
        analyzer: 'autocomplete',
        search_analyzer: 'standard',
      },
    },
  },
});

Service Pattern

class SearchService {
  constructor(private readonly es: Client) {}
 
  async indexDocument(index: string, id: string, doc: Record<string, unknown>) {
    await this.es.index({ index, id, document: doc });
  }
 
  async search(index: string, query: string, from = 0, size = 10) {
    const res = await this.es.search({
      index,
      from,
      size,
      query: {
        multi_match: { query, fields: ['name^2', 'description'], fuzziness: 'AUTO' },
      },
      highlight: { fields: { description: {} } },
    });
 
    return {
      total: res.hits.total,
      hits: res.hits.hits.map(h => ({
        id:         h._id,
        score:      h._score,
        source:     h._source,
        highlights: h.highlight,
      })),
    };
  }
 
  async delete(index: string, id: string) {
    await this.es.delete({ index, id });
  }
}

Common Mistakes

  • Using text type for exact-match filters — use keyword for filtering and aggregating
  • Not calling refresh after bulk indexing during tests, causing empty search results
  • Ignoring fuzziness — users make typos; set fuzziness: 'AUTO' for better recall
  • Running queries with huge size values — paginate with from/size or use scroll API
  • Storing large binary blobs or full HTML in Elasticsearch — store metadata only

Best Practices

  • Define explicit mappings before indexing — never rely on dynamic mapping in production
  • Use filter context for exact conditions (faster, cacheable) and must for scored full-text
  • Keep shard size between 10–50 GB; avoid thousands of tiny shards
  • Index writes to your primary DB first, then to Elasticsearch — treat ES as a read replica
  • Use index aliases so you can reindex without downtime by swapping aliases
  • Monitor query latency and rejected requests via Kibana or Grafana

Key Takeaways

  • Elasticsearch uses inverted indexes to achieve sub-100ms search on millions of documents
  • The @elastic/elasticsearch client ships TypeScript types for safe query construction
  • keyword fields are for exact matching and aggregations; text fields are for full-text search
  • Boolean queries combine must, filter, should, and must_not clauses for precision
  • Aggregations replace complex GROUP BY SQL — they run server-side with no data transfer
  • Custom analyzers with edge_ngram enable autocomplete and prefix search
  • Always define production mappings explicitly — dynamic mapping can create unintended field types
  • Write to your primary database first; sync to Elasticsearch asynchronously for resilience

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading