Membangun Fitur Pencarian Full-Text dengan Meilisearch dan Next.js: Indexing sampai Instant Search

Lhuqita Fazry
Web Development Next.js Meilisearch Full-Text Search TypeScript
Membangun Fitur Pencarian Full-Text dengan Meilisearch dan Next.js: Indexing sampai Instant Search

Mengapa Query SQL Saja Tidak Cukup untuk Pencarian yang Relevan

Query dengan pola LIKE '%keyword%' terasa mudah ditulis, tetapi hasilnya sering mengecewakan pengguna. Pola ini mencocokkan substring secara harfiah, tanpa memahami urutan kata maupun tingkat kecocokan. Dua dokumen yang sama-sama memuat kata kunci diperlakukan identik, sehingga hasil paling relevan bisa muncul di bawah hasil yang kebetulan cocok.

Keterbatasan pertama adalah tidak adanya ranking relevansi. LIKE hanya mengembalikan baris yang cocok, sementara urutannya ditentukan oleh ORDER BY yang tidak berhubungan dengan kualitas kecocokan. Keterbatasan kedua adalah ketahanan terhadap typo. Pengguna yang salah mengetik satu huruf tidak mendapat hasil apa pun. Keterbatasan ketiga muncul saat data membesar. Pola LIKE '%...%' tidak bisa memanfaatkan index B-Tree biasa karena wildcard di depan memaksa full table scan. Pada tabel dengan jutaan baris, satu request pencarian bisa memakan banyak waktu dan membebani database.

Search engine menyelesaikan masalah ini dengan inverted index. Struktur ini memetakan setiap kata ke daftar dokumen yang memuatnya, sehingga pencarian tidak perlu memindai seluruh isi tabel. Setiap kata diberi bobot berdasarkan frekuensi kemunculan, posisi, dan kelangkaannya. Skor relevansi seperti BM25 kemudian mengurutkan hasil, sehingga dokumen yang paling cocok tampil di urutan atas. Typo tolerance ditangani dengan pendekatan edit distance dan prefix matching.

Struktur inverted index yang memetakan setiap kata ke dokumen yang memuatnya

Gambar: Ilustrasi inverted index, setiap istilah dipetakan ke dokumen yang memuatnya — Sumber: Wikimedia Commons

Meilisearch menempatkan diri sebagai alternatif ringan dibanding Elasticsearch. Proses setup-nya cepat, typo tolerance aktif secara bawaan, dan API-nya sederhana. Alur kerjanya lugas: sumber data diekstrak, dokumen dikirim untuk diindeks, request pencarian masuk, lalu hasil yang sudah terurut dikembalikan. Kita tetap menyimpan data utama di database relasional, sementara Meilisearch mengambil peran khusus sebagai lapisan pencarian.

Alur kerja search engine dari sumber data, proses indeks, hingga query dan response

Gambar: Alur search engine — sumber data diekstrak crawler, diindeks, lalu melayani query dan mengembalikan response — Sumber: Wikimedia Commons

Menjalankan Meilisearch Secara Lokal dengan Docker

Cara tercepat menjalankan Meilisearch adalah melalui container Docker. Image resmi getmeili/meilisearch sudah berisi binary yang siap dipakai. Kita bisa memakai docker run untuk uji cepat atau docker-compose.yml agar konfigurasi tersimpan dan mudah direplikasi.

Environment variable MEILI_MASTER_KEY melindungi API dari akses tanpa izin, sedangkan MEILI_ENV menentukan mode operasi. Gunakan development untuk lokal dan production saat deployment. Nilai master key sebaiknya acak dan panjang, minimal 16 karakter.

yaml
# docker-compose.yml
services:
  meilisearch:
    image: getmeili/meilisearch:v1.10
    ports:
      - "7700:7700"
    environment:
      MEILI_MASTER_KEY: "local_master_key_change_me_123456"
      MEILI_ENV: development
    volumes:
      - ./meili_data:/meili_data
bash
docker compose up -d

curl -s http://localhost:7700/health
# {"status":"available"}

curl -s -H "Authorization: Bearer local_master_key_change_me_123456" \
  http://localhost:7700/version
# {"commitSha":"...","commitDate":"...","pkgVersion":"1.10.1"}

Endpoint /health mengembalikan status available jika service siap menerima request. Endpoint /version memastikan versi yang berjalan sesuai harapan. Volume meili_data menjaga index tetap ada meskipun container dihentikan, sehingga kita tidak perlu mengindeks ulang setiap kali me-restart service.

Dari sisi aplikasi Next.js, kita simpan alamat host dan key di .env.local sebagai MEILISEARCH_HOST dan MEILISEARCH_KEY. Kedua nilai ini hanya boleh diakses di server, bukan di bundle browser. Pemisahan environment antara lokal dan production mencegah aplikasi lokal tidak sengaja menulis ke index production.

Fullstack Web Development With Next.js
Web App • Beginner

Fullstack Web Development With Next.js

A practical, beginner-friendly, and project-based introduction to full-stack web...

Register

Mengindeks Data dari Sumber ke Meilisearch

Langkah pertama adalah memasang SDK resmi dengan npm install meilisearch. SDK ini menyediakan client yang menangani HTTP request ke Meilisearch, termasuk pengecekan status task. Sebelum dokumen dikirim, kita perlu mendefinisikan searchableAttributes dan filterableAttributes. Atribut yang bisa dicari menentukan kolom mana yang dipertimbangkan saat menghitung skor, sedangkan atribut yang bisa difilter membuka opsi penyaringan di query.

Urutan konfigurasi ini penting. Jika filterableAttributes diubah setelah dokumen masuk, Meilisearch akan melakukan reindex internal yang memakan waktu. Menetapkannya lebih dulu membuat proses ingest berjalan efisien.

javascript
import { MeiliSearch } from 'meilisearch';
import { db } from './db.js';

const client = new MeiliSearch({
  host: process.env.MEILISEARCH_HOST,
  apiKey: process.env.MEILISEARCH_KEY,
});

const INDEX = 'products';

async function setupIndex() {
  await client.createIndex(INDEX, { primaryKey: 'id' });
  await client.index(INDEX).updateSearchableAttributes([
    'name', 'description', 'category',
  ]);
  await client.index(INDEX).updateFilterableAttributes([
    'category', 'price', 'inStock',
  ]);
}

async function indexProducts(products) {
  const task = await client.index(INDEX).addDocuments(products);
  const result = await client.tasks.waitForTask(task.taskUid);
  console.log(`Task ${result.taskUid}: ${result.status}, ${result.details.indexedDocuments} dokumen`);
}

async function main() {
  await setupIndex();
  const products = await db.product.findMany({
    select: {
      id: true, name: true, description: true,
      category: true, price: true, inStock: true,
    },
  });
  await indexProducts(products);
}

main().catch(console.error);

Fungsi setupIndex membuat index dan menetapkan konfigurasi atribut. Fungsi indexProducts mengirim dokumen dalam batch melalui addDocuments, lalu menunggu task selesai dengan waitForTask. Proses indexing berjalan asinkron di sisi Meilisearch, sehingga kita harus memeriksa status task untuk memastikan data benar-benar masuk. Nilai indexedDocuments pada output memberi tahu berapa dokumen yang berhasil diproses.

Untuk update incremental, hindari menghapus seluruh index setiap kali data berubah. Kirim ulang hanya dokumen yang berubah dengan addDocuments memakai primaryKey yang sama, karena Meilisearch akan memperbarui dokumen yang sudah ada. Pola ini jauh lebih murah dibanding drop dan reindex penuh, terutama saat katalog berisi ratusan ribu entri.

Membangun UI Instant Search di Next.js

Komponen pencarian berjalan di sisi client karena input pengguna berubah cepat dan tidak boleh memicu render ulang server. Kita tandai file dengan directive 'use client'. Setiap perubahan kata kunci memanggil API route di Next.js, bukan Meilisearch secara langsung. API route ini menjadi proxy agar API key tetap berada di server dan tidak terekspos ke browser.

Tanpa debounce, satu kalimat yang diketik pengguna bisa memicu belasan request. Kita tunda eksekusi pencarian dengan setTimeout selama 250 ms dan batalkan timer sebelumnya setiap kali input berubah. AbortController membatalkan request yang masih berjalan ketika pengguna mengetik lebih cepat, sehingga hasil lama tidak menimpa hasil terbaru.

tsx
'use client';

import { useEffect, useState } from 'react';

type Hit = { id: string; name: string; category: string };

export function SearchBox() {
  const [query, setQuery] = useState('');
  const [hits, setHits] = useState<Hit[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (query.trim().length < 2) {
      setHits([]);
      return;
    }
    const controller = new AbortController();
    const timer = setTimeout(async () => {
      setLoading(true);
      setError(null);
      try {
        const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error('Pencarian gagal');
        const data = await res.json();
        setHits(data.hits);
      } catch (err) {
        if ((err as Error).name !== 'AbortError') setError('Tidak dapat memuat hasil');
      } finally {
        setLoading(false);
      }
    }, 250);

    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [query]);

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Cari produk..."
      />
      {loading && <p>Memuat...</p>}
      {error && <p>{error}</p>}
      {!loading && query.length >= 2 && hits.length === 0 && <p>Tidak ada hasil</p>}
      <ul>
        {hits.map((hit) => (
          <li key={hit.id}>
            <a href={`/products/${hit.id}`}>{hit.name} - {hit.category}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

Efek samping di atas mengelola siklus request secara menyeluruh. Ketika query lebih pendek dari dua karakter, komponen mengosongkan hasil untuk menghindari pencarian yang terlalu luas. Setelah request selesai, hasil disimpan di state dan langsung dirender. Tiga status yang wajib terlihat adalah loading, hasil kosong, dan pesan error, karena ketiganya membantu pengguna memahami apa yang sedang terjadi.

Untuk navigasi keyboard, kita bisa melacak indeks hasil yang aktif dan menangani tombol panah atas, panah bawah, serta Enter. Penambahan ini membuat pengguna bisa memilih hasil tanpa berpindah dari kolom input.

Menambah Filter, Facet, dan Highlighting Hasil

Pencarian yang baik tidak berhenti pada kata kunci. Kita kirim parameter filter untuk membatasi hasil berdasarkan kategori, harga, atau status stok. Meilisearch memakai sintaks ekspresi seperti category = "electronics" AND inStock = true. Atribut yang dipakai di dalam filter harus terdaftar di filterableAttributes, jika tidak, request akan ditolak.

facets mengaktifkan facetDistribution, yaitu objek berisi jumlah dokumen per nilai kategori. Data ini dipakai UI untuk menampilkan hitungan pada setiap pilihan filter, misalnya menampilkan bahwa kategori electronics berisi 128 hasil. Pengguna jadi tahu berapa banyak hasil yang akan muncul sebelum memilih filter.

attributesToHighlight bersama objek _formatted menandai bagian teks yang cocok dengan kata kunci. Kita bisa menentukan tag pembuka dan penutup sendiri, misalnya <mark> dan </mark>, lalu merendernya dengan aman di UI.

typescript
import { NextResponse } from 'next/server';
import { MeiliSearch } from 'meilisearch';

const client = new MeiliSearch({
  host: process.env.MEILISEARCH_HOST!,
  apiKey: process.env.MEILISEARCH_KEY!,
});

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const q = searchParams.get('q') ?? '';
  const category = searchParams.get('category');

  const filter = category
    ? `category = "${category}" AND inStock = true`
    : 'inStock = true';

  const result = await client.index('products').search(q, {
    filter,
    facets: ['category', 'price'],
    attributesToHighlight: ['name', 'description'],
    highlightPreTag: '<mark>',
    highlightPostTag: '</mark>',
    sort: ['price:asc'],
    limit: 20,
  });

  return NextResponse.json({
    hits: result.hits,
    facets: result.facetDistribution,
    total: result.estimatedTotalHits,
  });
}

Query di atas menggabungkan empat kemampuan sekaligus: pencarian kata kunci, filter kategori dan stok, sorting harga menaik, serta highlight. Field _formatted pada setiap hit berisi versi teks dengan tag penanda, sementara facetDistribution memberi hitungan per kategori. estimatedTotalHits menyediakan total perkiraan untuk keperluan pagination.

Catatan performa penting: selalu batasi limit dan terapkan pagination untuk dataset besar. Mengambil ribuan dokumen sekaligus memperbesar payload dan memperlambat rendering. Untuk pencarian dengan query kompleks, pantau latency dari dashboard Meilisearch dan sesuaikan batas hasil sesuai kebutuhan UI.

Mengamankan API Key dan Menyiapkan Konfigurasi Produksi

Meilisearch membedakan tiga jenis key. master key memiliki akses penuh dan hanya dipakai untuk administrasi, bukan oleh aplikasi. admin key bisa membuat index dan mengelola key lain. search-only key hanya diizinkan melakukan operasi pencarian pada index tertentu. Request pencarian dari browser harus memakai search-only key atau melewati proxy server seperti API route Next.js, sehingga key dengan hak istimewa tidak pernah sampai ke client.

Key dibuat dan dikelola melalui endpoint /keys. Kita bisa menentukan actions dan indexes yang boleh diakses agar key pencarian benar-benar terbatas. Untuk production, setiap environment memiliki key sendiri. Key untuk staging tidak boleh bisa membaca data production. Rotasi key secara berkala dan pencabutan key lama melalui endpoint /keys/{key} mengurangi risiko jika key bocor.

Sinkronisasi data sumber dengan index menjadi tantangan berikutnya. Pendekatan paling andal adalah memicu reindex saat data berubah, baik melalui event dari database maupun job queue setelah operasi create, update, dan delete berhasil. Sebagai lapisan pengaman, jadwalkan reindex penuh berkala untuk menutup kemungkinan data yang terlewat.

Monitoring dasar mencakup tiga hal: ukuran index, latency search, dan task yang gagal. Task gagal biasanya menandakan dokumen yang tidak sesuai atau konfigurasi atribut yang salah, jadi periksa error pada detail task dan perbaiki sumbernya. Checklist production mencakup pemisahan key per environment, pencadangan index, dan pembatasan akses network ke port Meilisearch agar hanya server aplikasi yang bisa menghubunginya.

Fitur pencarian yang relevan dibangun dari empat lapisan: service Meilisearch yang berjalan stabil, proses indexing yang mengikuti perubahan data, UI instant search yang responsif, dan konfigurasi key yang aman. Mulailah dari satu index kecil, kirim dokumen pertama, lalu ukur kualitas hasilnya sebelum memperluas ke seluruh katalog. Untuk memahami arsitektur Next.js dan integrasi layanan pencarian secara end-to-end, bergabunglah dengan kelas Web Development Rumah Coding.

Course Terkait

TechConnect - Modern IT Job Portal
Premium Course Web App

Fullstack Web Development With Next.js

A practical, beginner-friendly, and project-based introduction to full-stack web development. Students will learn to build, secure, and deploy modern web applications from scratch using Next.js (App Router), React, Tailwind CSS, and a relational database. By the end of the course, students will have a fully functional, production-ready application to showcase in their portfolio.

Capstone Project

TechConnect - Modern IT Job Portal

  • Public Job Board: A responsive homepage displaying available job listings with dynamic routing for individual job detail pages.
  • Search & Filter: Basic functionality allowing users to find jobs based on keywords or categories.
  • User Authentication: Secure Sign Up, Log In, and Log Out workflows.
7 Weeks Beginner
View Course Details

Related Articles