Meilisearch + HTMX: Sub-50ms Search in 14 KB, No Framework

Pair Meilisearch v1.12’s fast REST API with HTMX 2.0’s hx-get and hx-trigger attributes, and you get a real-time, typo-tolerant search box that returns results in under 50ms. You write no custom JavaScript and pull in no React or Vue. The server renders HTML fragments that HTMX swaps into the DOM, so the whole search box stays under 15 KB of total JS. This post covers the full setup, from Docker Compose to a working search UI with faceted filtering.

Why Meilisearch and HTMX Fit Together

Most web search follows a predictable pattern. You spin up Elasticsearch or Algolia on the backend. Then you ship a fat JavaScript bundle to the frontend so a framework like React can manage state, render results, and handle input. It works, but it costs you: bundle sizes north of 80 KB gzipped, complex build tooling, and a frontend codebase with its own maintenance cycle.

Meilisearch and HTMX take a different approach that skips most of that.

Meilisearch v1.12 is a search engine built for frontend-facing use. It gives you sub-50ms responses with built-in typo tolerance, faceted filtering, and relevancy tuning out of the box. Unlike Elasticsearch, it needs no cluster, no JVM, and no deep tuning to get good results. You run a single binary, push documents to it through a REST API, and start querying. The defaults work well for blog search, product catalogs, and documentation sites. The API is small enough to learn in an afternoon.

HTMX 2.0 works on the other side of the stack. It adds attributes like hx-get, hx-post, and hx-trigger to plain HTML elements, so you make AJAX requests and swap DOM content without writing any JavaScript. When a user types in a search field, HTMX sends a request to your backend. Your backend queries Meilisearch, renders an HTML fragment, and returns it. HTMX drops that fragment into the page. There are no other moving parts.

Consider the payload sizes. HTMX is about 14 KB gzipped. Your own JavaScript is zero bytes, because there is none. Compare that to a React plus InstantSearch.js setup, which ships 80 to 120 KB gzipped at minimum before you add your own components. For search on a blog or documentation site, that gap is real. It hits both page load times and the upkeep of a JavaScript build pipeline.

This setup also works with any backend language. The server just returns HTML strings, so you can use Python with Flask, Go with Chi, Node with Express, Ruby with Sinatra, or PHP with Laravel. There is no API contract to negotiate between a JSON backend and a JavaScript frontend, because the contract is HTML itself. For teams exploring this framework-free idea further, web components offer a related way to build reusable UI elements without framework lock-in.

The initial page load is fully server-rendered HTML, so search engines index it without running JavaScript. The search then layers on top as progressive enhancement. If JavaScript fails to load or is disabled, the form can fall back to a plain page refresh with query parameters. Accessibility and SEO work out of the box, with no extra engineering after the fact.

Setting Up Meilisearch with Docker Compose

Meilisearch delivers sub-50ms search-as-you-type with built-in typo tolerance

Meilisearch runs as a single binary or Docker container with minimal configuration. A production-ready setup takes about ten minutes.

Here is a docker-compose.yml that gets Meilisearch running with persistent storage:

version: "3.8"
services:
  meilisearch:
    image: getmeili/meilisearch:v1.12
    ports:
      - "7700:7700"
    environment:
      MEILI_MASTER_KEY: "your-32-character-minimum-master-key-here"
    volumes:
      - meili_data:/meili_data

volumes:
  meili_data:

Set MEILI_MASTER_KEY to a random string of at least 32 characters. This key controls admin access to the instance. In production, never expose port 7700 directly to the internet. Put it behind a reverse proxy like Nginx or Caddy with rate limiting on.

Once the container is running, create a search index:

curl -X POST 'http://localhost:7700/indexes' \
  -H 'Authorization: Bearer your-32-character-minimum-master-key-here' \
  -H 'Content-Type: application/json' \
  --data-binary '{"uid": "articles", "primaryKey": "id"}'

Index your documents by POSTing a JSON array. For a blog, each document should include the fields you want to search and display:

curl -X POST 'http://localhost:7700/indexes/articles/documents' \
  -H 'Authorization: Bearer your-32-character-minimum-master-key-here' \
  -H 'Content-Type: application/json' \
  --data-binary '[
    {
      "id": 1,
      "title": "Getting Started with Docker Compose",
      "content": "Docker Compose simplifies multi-container...",
      "tags": ["docker", "devops"],
      "date": "2026-01-15",
      "slug": "getting-started-docker-compose"
    }
  ]'

Configure searchable attributes to control match priority. Title matches should rank higher than body content:

curl -X PUT 'http://localhost:7700/indexes/articles/settings/searchable-attributes' \
  -H 'Authorization: Bearer your-32-character-minimum-master-key-here' \
  -H 'Content-Type: application/json' \
  --data-binary '["title", "tags", "content"]'

For frontend use, generate a search-only API key through the /keys endpoint. This key is safe to expose in client-side requests, because it can only search. It cannot change indexes, settings, or documents.

If you run a Hugo or other static site, write a build-time indexing script. It reads your Markdown files, pulls out frontmatter and content, and pushes everything to the Meilisearch API during deploy. A small Python script using python-frontmatter and requests handles this in under 50 lines:

import frontmatter
import requests
from pathlib import Path

MEILI_URL = "http://localhost:7700"
MEILI_KEY = "your-master-key"
POSTS_DIR = Path("content/posts")

documents = []
for i, md_file in enumerate(POSTS_DIR.rglob("*.md")):
    post = frontmatter.load(md_file)
    documents.append({
        "id": i,
        "title": post.get("title", ""),
        "content": post.content[:5000],
        "tags": post.get("tags", []),
        "date": str(post.get("date", "")),
        "slug": md_file.parent.name
    })

requests.post(
    f"{MEILI_URL}/indexes/articles/documents",
    json=documents,
    headers={"Authorization": f"Bearer {MEILI_KEY}"}
)

Run this script as part of your CI/CD pipeline after the site builds, and your search index stays in sync with your content.

Building the HTMX Search Interface

The frontend is plain HTML with HTMX attributes. You need no build step, no bundler, and no component library. Here is the complete search interface:

<div class="search-container">
  <input
    type="search"
    name="q"
    hx-get="/search"
    hx-trigger="keyup changed delay:300ms"
    hx-target="#results"
    hx-indicator="#spinner"
    hx-push-url="true"
    placeholder="Search articles..."
    autocomplete="off"
  >
  <span id="spinner" class="htmx-indicator">Searching...</span>
  <div id="results"></div>
</div>

Each attribute does something specific:

  • hx-get="/search" sends a GET request to your /search endpoint with the input value as a query parameter
  • hx-trigger="keyup changed delay:300ms" debounces the request. It fires 300ms after the user stops typing, so the API is not hit on every keystroke
  • hx-target="#results" tells HTMX to swap the response HTML into the #results div
  • hx-indicator="#spinner" shows the spinner during the request, which gives visual feedback on slow connections
  • hx-push-url="true" updates the browser URL with the search query, so users can share result links and the back button works as expected

The results themselves are plain HTML returned by the server. Meilisearch provides _formatted fields with <em> tags wrapping matched terms, so highlighting comes for free:

<article class="search-result">
  <h3><a href="/posts/getting-started-docker-compose/">
    Getting Started with <em>Docker</em> Compose
  </a></h3>
  <p><em>Docker</em> Compose simplifies multi-container application
  deployment by defining services in a single YAML file...</p>
  <span class="search-meta">2026-01-15 - docker, devops</span>
</article>

Style it with whatever CSS you already use on your site. There is no framework-specific markup to work around.

The Backend Search Endpoint

The backend is a thin proxy. It takes the query from HTMX, asks Meilisearch for results, renders an HTML fragment, and returns it. Here is a complete version in Python using Flask :

from flask import Flask, request, render_template_string
import meilisearch

app = Flask(__name__)
client = meilisearch.Client("http://localhost:7700", "your-search-only-key")
index = client.index("articles")

RESULT_TEMPLATE = """
{% for hit in hits %}
<article class="search-result">
  <h3><a href="/posts/{{ hit.slug }}/">{{ hit._formatted.title | safe }}</a></h3>
  <p>{{ hit._formatted._snippetContent | safe }}</p>
  <span class="search-meta">{{ hit.date }}</span>
</article>
{% endfor %}
{% if not hits %}
<p class="no-results">No results found.</p>
{% endif %}
"""

@app.route("/search")
def search():
    q = request.args.get("q", "").strip()
    if not q:
        return "<div></div>"

    results = index.search(q, {
        "limit": 10,
        "attributesToHighlight": ["title", "content"],
        "attributesToCrop": ["content"],
        "cropLength": 30
    })

    return render_template_string(RESULT_TEMPLATE, hits=results["hits"])

The equivalent in Node.js with Express looks almost identical:

const express = require("express");
const { MeiliSearch } = require("meilisearch");

const app = express();
const client = new MeiliSearch({
  host: "http://localhost:7700",
  apiKey: "your-search-only-key"
});
const index = client.index("articles");

app.get("/search", async (req, res) => {
  const q = (req.query.q || "").trim();
  if (!q) return res.send("<div></div>");

  const results = await index.search(q, {
    limit: 10,
    attributesToHighlight: ["title", "content"],
    attributesToCrop: ["content"],
    cropLength: 30
  });

  const html = results.hits.map(hit => `
    <article class="search-result">
      <h3><a href="/posts/${hit.slug}/">${hit._formatted.title}</a></h3>
      <p>${hit._formatted.content}</p>
      <span class="search-meta">${hit.date}</span>
    </article>
  `).join("");

  res.send(html || '<p class="no-results">No results found.</p>');
});

app.listen(3000);

A few important details in both implementations:

The attributesToCrop option with cropLength: 30 tells Meilisearch to return snippets of about 30 words around the match. This keeps the response small and the results clean. You do not send the whole article body to the browser just to show a preview.

When the query is empty or missing, return an empty <div> so the results area clears out. Without this, stale results from the last search stick around after the user clears the field.

For production, add response caching. A 60-second TTL through HTTP Cache-Control headers or a server-side cache (Redis, an in-memory dictionary, or a simple LRU cache) cuts Meilisearch load on popular queries. Most search traffic follows a power law: a few queries account for most of the load.

Advanced Features: Facets, Filters, and Keyboard Navigation

Once basic search works, you can layer on additional features without touching a JavaScript framework.

Faceted Filtering

Enable faceted search by updating your Meilisearch index settings:

curl -X PATCH 'http://localhost:7700/indexes/articles/settings' \
  -H 'Authorization: Bearer your-master-key' \
  -H 'Content-Type: application/json' \
  --data-binary '{
    "filterableAttributes": ["tags", "category"],
    "sortableAttributes": ["date"]
  }'

Add a category filter dropdown alongside the search input:

<select
  name="category"
  hx-get="/search"
  hx-include="[name='q']"
  hx-trigger="change"
  hx-target="#results"
>
  <option value="">All Categories</option>
  <option value="linux">Linux</option>
  <option value="docker">Docker</option>
  <option value="ai">AI</option>
</select>

The hx-include="[name='q']" attribute tells HTMX to send the current search value when the dropdown changes. On the backend, turn the category parameter into a Meilisearch filter:

@app.route("/search")
def search():
    q = request.args.get("q", "").strip()
    category = request.args.get("category", "").strip()

    search_params = {
        "limit": 10,
        "attributesToHighlight": ["title", "content"],
        "attributesToCrop": ["content"],
        "cropLength": 30
    }

    if category:
        search_params["filter"] = f'category = "{category}"'

    results = index.search(q, search_params)
    return render_template_string(RESULT_TEMPLATE, hits=results["hits"])

Keyboard Navigation

Add arrow-key navigation between search results with a small inline script attached to the HTMX swap event:

<div id="results" hx-on::after-swap="
  const first = this.querySelector('a');
  if (first) first.focus();
">
</div>

This focuses the first link after results load. For full arrow-key support between results, three lines of JavaScript do the job:

document.addEventListener("keydown", (e) => {
  if (e.key === "ArrowDown" || e.key === "ArrowUp") {
    const links = [...document.querySelectorAll("#results a")];
    const i = links.indexOf(document.activeElement);
    const next = e.key === "ArrowDown" ? i + 1 : i - 1;
    if (links[next]) { links[next].focus(); e.preventDefault(); }
  }
});

That is the only custom JavaScript in the entire search implementation.

Infinite Scroll Pagination

For longer result sets, add “search as you scroll” with a sentinel element at the bottom of the results:

<div
  hx-get="/search?q=docker&page=2"
  hx-trigger="revealed"
  hx-swap="outerHTML"
>
  Loading more results...
</div>

The hx-trigger="revealed" fires when the element scrolls into view. It loads the next page of results and swaps the sentinel for new result items plus a fresh sentinel for page 3. On the backend, pass the offset parameter to Meilisearch: search(q, {"offset": (page - 1) * 10, "limit": 10}). If you prefer numbered page links over infinite scroll, a lightweight jQuery pagination widget like bootpag renders the page buttons and fires a page event you can wire to the same /search?page=N endpoint.

Search Analytics

Meilisearch ships no built-in query analytics, but you can add basic tracking with little effort. Log each query, the number of results, and a timestamp to a SQLite database on the backend:

import sqlite3

db = sqlite3.connect("search_analytics.db")
db.execute("""
    CREATE TABLE IF NOT EXISTS queries (
        q TEXT, hits INTEGER, ts DATETIME DEFAULT CURRENT_TIMESTAMP
    )
""")

# Inside your search route, after getting results:
db.execute("INSERT INTO queries (q, hits) VALUES (?, ?)",
           (q, len(results["hits"])))
db.commit()

This gives you a “top searches” report and zero-result queries (which reveal content gaps) with a single SQL query. You need no external analytics service.

Performance Comparison

The real difference between this stack and a framework-heavy one is large:

MetricMeilisearch + HTMXAlgolia + React + InstantSearch.js
JS payload (gzipped)~14 KB80-120 KB
Search latency (p95)<50ms<50ms
Build tooling requiredNoneWebpack/Vite + npm
Backend languages supportedAnyAny (but JS frontend locked in)
Time to interactiveFast (minimal JS parsing)Slower (framework hydration)

Both stacks deliver fast search results from the server side. The difference shows up in everything around the search: the JavaScript your users download, the toolchain you maintain, and the mental load of managing client-side state. If your search lives on a blog, documentation site, or content-heavy app where you have no React codebase already, Meilisearch plus HTMX gets you the same result with a fraction of the complexity.

The full setup is Docker Compose, a Python or Node backend, and an HTML template. It can be up and running in under an hour. You skip the node_modules folder, the build pipeline, and the framework upgrade treadmill entirely.