/ astro-integrations / How to Integrate Orama with Astro: Complete Guide
astro-integrations 12 min read

How to Integrate Orama with Astro: Complete Guide

Step-by-step guide to integrating Orama with your Astro website.

How to Integrate Orama with Astro: Complete Guide

Orama is a full-text search engine that runs entirely in the browser. No server, no external API, no third-party service. Your search index is a JSON file that ships with your site, and all search queries execute client-side in JavaScript. For static Astro sites, this means you get instant search with zero infrastructure costs and zero API latency.

Orama handles typo tolerance, stemming, faceted search, and even vector and hybrid search, all in a core library that the package itself describes as "less than 2kb." It is particularly well-suited for documentation sites, blogs, and knowledge bases where the content is known at build time.

This guide was checked against the current packages on 2026-05-29. Verified versions at the time of writing: @orama/orama 3.1.18, @orama/plugin-data-persistence 3.1.18, @orama/plugin-match-highlight 3.1.18, @orama/stemmers 3.1.18, and Astro 6.4.2. A note on the API: Orama v3 dropped the old top-level save/load exports from @orama/orama. Index persistence now lives in the @orama/plugin-data-persistence package as persist/restore, and the examples below use that current path.

Prerequisites

  • Node.js 20 or newer. The published @orama/orama 3.1.18 declares engines.node: ">= 20.0.0", so Node 18 is no longer supported.
  • An Astro project (npm create astro@latest). Astro is at 6.x, and it builds to static HTML by default.
  • No external services needed.

Installation

The core search library:

npm install @orama/orama

To persist a prebuilt index and restore it in the browser, add the data-persistence plugin (this replaces the old save/load exports):

npm install @orama/plugin-data-persistence

For optional extras like match highlighting and language-specific stemming:

npm install @orama/plugin-match-highlight @orama/stemmers

All four packages are published at version 3.1.18 and are kept in lockstep, so install them at the same major.minor to avoid API drift.

Creating the Search Index at Build Time

The key to using Orama with Astro is generating the search index during the build process. Create a script that reads your content and produces a JSON index. Note the imports: create and insert come from @orama/orama, while persist (the function that serializes the database) comes from @orama/plugin-data-persistence. In Orama v3, persist returns a serialized snapshot in json, binary, or dpack format. We use json here so the index ships as plain JSON.

// scripts/build-search-index.ts
import { create, insert } from "@orama/orama";
import { persist } from "@orama/plugin-data-persistence";
import fs from "fs";
import path from "path";
import matter from "gray-matter";

const postsDir = path.join(process.cwd(), "public/data/posts");
const files = fs.readdirSync(postsDir).filter((f) => f.endsWith(".mdx"));

const db = await create({
  schema: {
    title: "string",
    description: "string",
    content: "string",
    slug: "string",
    category: "string",
    tags: "string",
    author: "string",
  },
});

for (const file of files) {
  const raw = fs.readFileSync(path.join(postsDir, file), "utf-8");
  const { data, content } = matter(raw);

  if (data.draft) continue;

  // Strip MDX/markdown syntax for cleaner search content
  const plainContent = content
    .replace(/```[\s\S]*?```/g, "") // Remove code blocks
    .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text
    .replace(/#{1,6}\s/g, "") // Remove headings
    .replace(/[*_~`]/g, "") // Remove formatting
    .slice(0, 5000);

  await insert(db, {
    title: data.title || "",
    description: data.description || "",
    content: plainContent,
    slug: file.replace(".mdx", ""),
    category: data.category || "",
    tags: (data.tags || []).join(", "),
    author: data.author || "",
  });
}

// persist(db, "json") returns the serialized snapshot. Write it as-is.
const serialized = await persist(db, "json");
const outputPath = path.join(process.cwd(), "public", "search-index.json");
fs.writeFileSync(outputPath, serialized as string);

console.log("Search index built with " + files.length + " posts");

If you prefer to skip the JSON.stringify step entirely, @orama/plugin-data-persistence/server exposes persistToFile(db, "json", filePath), which writes the snapshot straight to disk. That server entry uses Node's fs module, so it only runs in a build or server context, never in the browser.

Add it to your build process:

{
  "scripts": {
    "build:search": "npx tsx scripts/build-search-index.ts",
    "build": "npm run build:search && astro build"
  }
}

Building the Search Component

Create an Astro component that loads the index and runs searches client-side:

---
// src/components/OramaSearch.astro
---

<div id="orama-search">
  <input
    type="text"
    id="search-input"
    placeholder="Search articles..."
    autocomplete="off"
  />
  <ul id="search-results"></ul>
</div>

<script>
  import { search } from "@orama/orama";
  import { restore } from "@orama/plugin-data-persistence";

  let db: any = null;

  async function initSearch() {
    if (db) return;

    const response = await fetch("/search-index.json");
    const data = await response.text();

    // restore() rebuilds the full Orama instance from the serialized
    // snapshot, so there is no need to call create() and redefine the
    // schema on the client. The format ("json") must match what the
    // build script passed to persist().
    db = await restore("json", data);
  }

  const input = document.getElementById("search-input") as HTMLInputElement;
  const resultsContainer = document.getElementById("search-results") as HTMLUListElement;
  let debounceTimer: ReturnType<typeof setTimeout>;

  input.addEventListener("focus", () => {
    initSearch(); // Load index on first focus
  });

  input.addEventListener("input", () => {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(async () => {
      await initSearch();

      const query = input.value.trim();
      if (!query) {
        resultsContainer.replaceChildren();
        return;
      }

      const results = await search(db, {
        term: query,
        properties: ["title", "description", "content"],
        boost: { title: 3, description: 2, content: 1 },
        limit: 10,
        tolerance: 1,
      });

      resultsContainer.replaceChildren();
      results.hits.forEach((hit: any) => {
        const li = document.createElement("li");
        li.className = "search-hit";

        const link = document.createElement("a");
        link.href = "/blog/" + hit.document.slug;

        const title = document.createElement("h3");
        title.textContent = hit.document.title;

        const desc = document.createElement("p");
        desc.textContent = hit.document.description;

        link.appendChild(title);
        link.appendChild(desc);
        li.appendChild(link);
        resultsContainer.appendChild(li);
      });
    }, 150);
  });
</script>

<style>
  #orama-search {
    position: relative;
    max-width: 600px;
  }
  #search-input {
    width: 100%;
    padding: 12px 16px;
    border: 1px solid #ddd;
    border-radius: 8px;
    font-size: 16px;
  }
  .search-hit {
    list-style: none;
    padding: 12px 16px;
    border-bottom: 1px solid #eee;
  }
  .search-hit:hover {
    background: #f5f5f5;
  }
  .search-hit a {
    text-decoration: none;
    color: inherit;
  }
  .search-hit h3 {
    margin: 0 0 4px;
    font-size: 16px;
  }
  .search-hit p {
    margin: 0;
    font-size: 14px;
    color: #666;
  }
</style>

Adding Search Highlighting

Use the highlight plugin to show which parts of the text matched. Two things matter for the current API. First, you register the plugin's afterInsert hook on the database. Second, the highlight positions are only returned when you query with the plugin's own searchWithHighlight function rather than the core search function. Both come from @orama/plugin-match-highlight:

import { create, insert } from "@orama/orama";
import {
  afterInsert as highlightAfterInsert,
  searchWithHighlight,
} from "@orama/plugin-match-highlight";

const db = create({
  schema: {
    title: "string",
    description: "string",
    content: "string",
    slug: "string",
  },
  plugins: [
    {
      name: "highlight",
      afterInsert: highlightAfterInsert,
    },
  ],
});

insert(db, {
  title: "Hello world",
  description: "",
  content: "",
  slug: "hello-world",
});

const results = searchWithHighlight(db, { term: "hello" });

The plugin attaches the matched token positions to each hit, which you can use to wrap matched terms in highlight elements. If you persist a highlight-enabled index, use the plugin's saveWithHighlight and loadWithHighlight functions instead of the generic persist/restore, so the highlight metadata survives the round trip.

Orama supports faceted search for filtering by category, tags, or any other field:

const results = await search(db, {
  term: "astro",
  properties: ["title", "description"],
  facets: {
    category: {
      limit: 10,
      order: "DESC",
    },
    tags: {
      limit: 20,
      order: "DESC",
    },
  },
});

// results.facets.category.values gives you:
// { "Tutorials": 15, "Guides": 8, "News": 3 }

Use the facet counts to build filter buttons that refine search results.

Lazy Loading the Index

The search index can get large for sites with thousands of posts. Lazy load it so it does not affect initial page load:

import { restore } from "@orama/plugin-data-persistence";

// Only build the database when the user actually searches.
let dbPromise: Promise<any> | null = null;

function ensureIndex() {
  if (!dbPromise) {
    dbPromise = fetch("/search-index.json")
      .then((res) => res.text())
      .then((data) => restore("json", data));
  }
  return dbPromise;
}

For very large sites, consider splitting the index into chunks by category or date range, and loading only the relevant chunk.

Production Tips

  1. Strip unnecessary content from the index. Code blocks, HTML tags, and image references add bulk without improving search quality. Clean your content before indexing.

  2. Set appropriate tolerance. The tolerance parameter is the maximum Levenshtein edit distance Orama allows between the term and a match. A value of 1 catches most typos without returning irrelevant results. A tolerance of 2 is more forgiving but may reduce precision. Note that tolerance is ignored when you also pass exact: true, since exact mode takes precedence. If you want all query words to be required rather than any, set threshold: 0 instead.

  3. Use boost weights. Weight title matches higher than content matches. A title match for "Astro deployment" is more relevant than the word appearing once in a 3,000-word article.

  4. Compress the index. Gzip your search-index.json on the server. Most hosting providers handle this automatically, but verify that the file is served compressed.

  • Rebuild on content changes. Add the index build to your CI/CD pipeline so the search index updates whenever you publish new content.

  • A Note on Astro Output Modes

    You do not need an Astro adapter or any special output configuration for this setup. Astro builds to static HTML by default, and the entire Orama flow here is build-time indexing plus client-side querying, so it works on a plain static build. This matters because the output options changed in Astro 5: the separate output: 'hybrid' mode was removed, and static is now the default that also handles mixed rendering. If you set a page to export const prerender = false, Astro switches that route to on-demand rendering, and on-demand rendering is the only case that requires installing an adapter. None of that applies to a client-side Orama search, which is why this approach stays adapter-free on Astro 6.

    Alternatives to Consider

    • Pagefind if you want a similar build-time approach with automatic indexing and no manual schema definition.
    • Meilisearch if you need server-side search with more advanced features like geo-search and scoped API keys.
    • Algolia if you need analytics, A/B testing, and managed infrastructure.

    Wrapping Up

    Orama gives Astro sites powerful search with no infrastructure dependencies. The entire search engine runs in the browser, which means zero API calls, zero latency from network requests, and zero ongoing costs. For blogs, documentation sites, and any Astro project where content is known at build time, Orama is the simplest path to high-quality search. Build the index, ship the JSON, and search works everywhere your site loads.

    Common Errors and Fixes

    save or load is not exported by @orama/orama. This is the most common upgrade trap. In Orama v3, persistence moved out of the core package. Import persist and restore from @orama/plugin-data-persistence (or persistToFile and restoreFromFile from @orama/plugin-data-persistence/server). The old single-package save/load API is gone.

    fs is not defined or a stream error in the browser. The @orama/plugin-data-persistence/server entry uses Node's fs module and is documented as server-only. It throws in browsers and in runtimes without a Node-compatible fs. In the client component, fetch the snapshot yourself and call restore("json", data) from the base @orama/plugin-data-persistence import, which is what the search component above does.

    The Node engine warning on install. @orama/orama 3.1.18 declares engines.node: ">= 20.0.0". On Node 18 you will see an EBADENGINE warning, and older Node may fail outright. Use Node 20 or newer.

    Format mismatch between persist and restore. persist(db, "json") and restore("json", data) must use the same format string. If the build script writes binary or dpack but the client restores json, the restore fails or returns an empty index. Keep the format consistent on both ends, and remember that a binary snapshot is not plain JSON you can fetch as text.

    Highlight positions are missing from results. Registering the afterInsert hook is not enough on its own. You must query with searchWithHighlight from @orama/plugin-match-highlight rather than the core search function, otherwise the match positions are not attached to the hits.

    Stemmer import path is per language. @orama/stemmers does not export a single default stemmer. Import the language you need, for example import { stemmer, language } from "@orama/stemmers/english";, and wire it through components.tokenizer with stemming: true. Importing the bare package root will not give you a working stemmer.

    Search returns nothing after persistence. A known sharp edge is that a restored database can produce slightly different results from the live one if the schema or tokenizer configuration differs between build and runtime. Keep the schema identical, and prefer restore (which rebuilds the full instance) over manually recreating the database and replaying data.

    Official Docs and Examples

    Sources

    All versions and API facts below were checked on 2026-05-29.