The Right Way to Add Orama Search to Astro

Stop using the Orama Astro plugin. Here is how to build a type-safe, metadata-rich search engine that actually works in Dev Mode.

Pixel art of a vintage computer with a glowing green screen surrounded by lush plants.

I love static sites. They are fast, secure, and cheap to host. But search has always been the weak point.

You usually have two options:

  1. External Services: Algolia or similar self-hosted services like Meilisearch.
  2. Client-Side Libraries: Orama, Pagefind, Lunr or Fuse.

I was looking for something that runs entirely in the browser, supports fuzzy matching, handles typos, and doesn’t cost me a dime.

Enter Orama (formerly Lyra). It’s an immutable, in-memory, full-text search engine written in TypeScript. It’s fast.

However, most tutorials (including the official docs) tell you to use the @orama/plugin-astro. Do not do this.

The plugin has three major problems:

  1. No Dev Mode: It only generates the index during astro build. If you run pnpm dev, your search bar 404s.
  2. Weak Metadata: It scrapes your HTML output. If you want to display a “Thumbnail” or “Author” in your search results, you have to build awkward side-channel maps.
  3. No Control: You can’t easily boost specific fields (like title > body) without fighting the config.

Here is how to implement Orama the right way using Astro Endpoints.

The Architecture

Instead of scraping HTML, we generate the index directly from Astro Content Collections.

  1. Build Time: We create a .json.ts endpoint that fetches all posts, builds an Orama DB, and serializes it to JSON.
  2. Client Time: The browser fetches this JSON file (lazy-loaded) and hydrates a local Orama instance.

This solves the Dev Mode problem immediately because Astro treats .json.ts files as live routes.

1. The Index Endpoint

Create a file at src/pages/search-index.json.ts. This acts as our “Search API.”

Defining a strict schema is important. Orama performs best when it knows exactly what fields to expect. By specifying fields like title, slug, date, and description, you optimize the index for performance and ensure each search result contains everything needed to render its card.

import { create, insertMultiple, save } from '@orama/orama';
import { getCollection } from 'astro:content';

export async function GET() {
  const posts = await getCollection('blog', ({ data }) => !data.draft);

  // 1. Create the DB instance
  const db = await create({
    schema: {
      title: 'string',
      slug: 'string',
      description: 'string',
      date: 'string', // Store as string for easy display
      tags: 'string[]',
    },
  });

  // 2. Insert Data
  const records = posts.map(post => ({
    title: post.data.title,
    slug: `/blog/${post.slug}`,
    description: post.data.description,
    date: post.data.date.toISOString(),
    tags: post.data.tags,
  }));

  await insertMultiple(db, records);

  // 3. Serialize to JSON
  const index = await save(db);

  return new Response(JSON.stringify(index), {
    headers: {
      'Content-Type': 'application/json',
    },
  });
}

2. The Search Component (Client-Side)

On the client, we fetch this JSON and “hydrate” Orama. I use Preact here to save bundle size, but the logic is framework-agnostic.

We use load to restore the database state. This is much faster than re-indexing data on the client.

import { useState, useEffect } from 'preact/hooks';
import { create, load, search, type Orama } from '@orama/orama';

// Define the same schema for type safety
const SCHEMA = {
  title: 'string',
  slug: 'string',
  description: 'string',
  date: 'string',
  tags: 'string[]',
} as const;

export default function SearchModal() {
  const [db, setDb] = useState<Orama<typeof SCHEMA> | null>(null);
  const [results, setResults] = useState([]);

  // Lazy load: Only fetch the index when the user interactions with search
  const initSearch = async () => {
    if (db) return;

    // 1. Fetch the raw JSON index
    const response = await fetch('/search-index.json');
    const index = await response.json();

    // 2. Initialize an empty DB
    const newDb = await create({ schema: SCHEMA });

    // 3. Load the snapshot
    await load(newDb, index);
    setDb(newDb);
  };

  const handleSearch = async (term: string) => {
    if (!db || !term) return;

    const searchResult = await search(db, {
      term,
      limit: 5,
      threshold: 0.2, // Tolerance for fuzziness
      boost: {
        title: 2, // Title matches are 2x more important
      },
    });

    setResults(searchResult.hits);
  };

  return (
    // ... UI Markup (Input + Results List)
  );
}

Why This Approach Wins

1. Zero-Config Dev Mode

Because src/pages/search-index.json.ts is just a standard Astro route, it works perfectly in pnpm dev. You don’t need custom scripts to force-build an index or manual file copying.

2. Rich, Type-Safe Metadata

We aren’t scraping <h1> tags and hoping for the best. We use the Content Collection schema directly. The search result object contains exactly what we need to render the UI:

// The `hit.document` is fully typed
{
  title: "Building Static Search...",
  slug: "/blog/building-static-search",
  date: "2026-01-12T00:00:00.000Z",
  tags: ["astro", "search"]
}

3. Relevance Tuning

Notice the boost: { title: 2 } in the search query? If a user searches for “Astro”, a post with “Astro” in the title should rank higher than a post that just mentions it in the footer. The official plugin abstracts this away. Here, we have full control.

When NOT to use it

I’ve talked Orama up, but let’s be honest about the trade-offs.

The Bandwidth Issue

Orama is an In-Memory search engine. This means the entire index must be loaded into the user’s browser RAM.

  • 50 Posts: Index is ~30KB (Gzipped). Perfect.
  • 500 Posts: Index is ~300KB. Manageable.
  • 5,000 Posts: Index is ~3MB+. Unusable.

If you have thousands of pages, forcing the user to download a 3MB JSON file just to search is bad UX.

The Alternative: For larger sites, use Pagefind. Pagefind splits the index into small “chunks.” It only downloads the chunks relevant to the search query. It scales to 100k+ pages while keeping initial bandwidth low.

In my implementation above, I indexed the description, not the full body.

Why? Size. Indexing the full body content of 100 posts bloats the JSON file significantly. For a blog, searching titles, descriptions, and tags is usually 95% of what users need. If you must search the full body text, be prepared for the file size to triple.

Solving the Bandwidth Problem

The src/pages/search-index.json.ts architecture solves the dev mode and metadata issues, but as your blog grows, you will hit the Bandwidth Wall.

If you index the full body of 200 posts, your JSON file could easily exceed 2MB. Here is how to solve this using Schema Optimization and AI-Powered Semantic Compression.

1. Schema Optimization (The Low-Hanging Fruit)

The fastest way to bloat your index is to index fields you don’t actually search.

Bad Schema:

const schema = {
  title: 'string',
  slug: 'string',
  body: 'string', // 🚨 DANGER: Indexing 5,000 words per post
  author: 'string',
  tags: 'string[]',
  image: 'string', // 🚨 DANGER: Why are you indexing a URL string?
};

Optimized Schema:

const schema = {
  title: 'string',
  slug: 'string',
  summary: 'string', // Index the description or a generated summary
  tags: 'string[]',
  // Store metadata (image, date) but DO NOT index it for search
};

Tip: Orama allows you to store data without indexing it. In our JSON generation script, we only feed Orama the searchable text. The metadata needed for the UI (like image or date) can be stored in the document payload but ignored by the indexing engine.

2. AI-Powered “Semantic Compression”

This is the interesting part. Instead of indexing your raw Markdown (which is full of noise like “the”, “and”, code blocks, and imports), use an LLM at build time to generate a Search Profile for each post.

The Concept:

  1. Read the full blog post content during the build.
  2. Pass it to a cheap LLM (like GPT-4o-mini or a local Llama 3 via Ollama).
  3. Generate a dense, keyword-rich summary specifically designed for search.
  4. Index that summary instead of the body.

Why this works: A 3,000-word tutorial on “Setting up Nginx” might only contain 50 words of unique, searchable signal. By indexing only the signal, you reduce your index size by ~90% while improving relevance because you remove the noise.

The Build Script (scripts/generate-search-embeddings.ts):

import { getCollection } from 'astro:content';
import { generateText } from 'ai';
import { openai } from '@ai-sdk/openai';

// This runs ONLY at build time. No client-side cost.
async function generateSearchIndex() {
  const posts = await getCollection('blog');
  const searchIndex = [];

  for (const post of posts) {
    // Check if we have a cached summary to save API costs
    let searchContent = post.data.search_summary;

    if (!searchContent) {
      const { text } = await generateText({
        model: openai('gpt-4o-mini'),
        prompt: `
          Analyze this blog post about "${post.data.title}".
          Extract the core technical concepts, libraries mentioned, problems solved,
          and key takeaways.
          Output a single paragraph of dense, keyword-rich text optimized for a search engine.
          Do not use filler words.

          Content: ${post.body.substring(0, 8000)}
        `,
      });
      searchContent = text;
    }

    searchIndex.push({
      title: post.data.title,
      slug: `/blog/${post.slug}`,
      // Index this AI-generated "dense" content instead of the body
      content: searchContent,
      tags: post.data.tags,
    });
  }

  // Save to JSON...
}

3. Chunking Long Content

If you have massive posts (like a “Complete Guide to Linux” with 10 chapters), a single search result pointing to the top of the page is annoying. The user wants to jump to the specific section (like “Permissions”).

The Strategy: Don’t treat one Post as one Document. Treat one H2 Section as one Document.

  1. Split your markdown by ## H2 headers.
  2. Create an Orama document for each section.
  3. Add an anchor hash to the slug (e.g., /blog/linux-guide#permissions).
// Transform 1 Post into N Search Documents
const records = [];

posts.forEach(post => {
  const sections = splitByH2(post.body); // Helper function

  sections.forEach(section => {
    records.push({
      title: `${post.data.title} > ${section.heading}`,
      slug: `/blog/${post.slug}#${section.slug}`,
      content: section.text.substring(0, 1000), // or use AI summary of section
    });
  });
});

This increases the number of documents (which increases index size slightly), but it improves the user experience for long-form content.

Summary of Techniques

TechniqueImplementation DifficultyIndex Size ReductionRelevance Impact
Schema OptimizationLowHigh (20-40%)Neutral
Stop Word RemovalLow (Orama config)Moderate (10-15%)Neutral
AI Semantic CompressionHigh (Requires API/LLM)Massive (80-90%)Positive
Section ChunkingModerateNegative (Increases Size)Positive (UX)

For a personal blog, Technique #2 (AI Compression) is the sweet spot. It keeps your search index tiny (sub-50KB) while making your search feel “smart” because it matches concepts, not just exact keywords.

Conclusion

For a developer blog, portfolio, or documentation site under 500 pages, Orama + Astro Endpoints is the best option. You get instant, app-like search without the infrastructure headache.

But if you are building the next Wikipedia, don’t make your users download the whole database.


What’s next?

This is part of a series of posts on implementing search for static sites:

  • The Right Way to Add Orama Search to Astro (you are here) — simple, zero-config search for small to medium sites
  • Why I Switched from Orama to Pagefind — chunked index for better scalability
  • Meilisearch is the Best Search You’ll Never Need — server-side search with advanced features
  • Why I Didn’t Use Google Programmable Search (coming soon) — the hidden costs and indexing delays that make it impractical
  • I Tried 4 Search Engines So You Don’t Have To (coming soon) — comprehensive comparison from a small blog perspective

All with practical examples from a real production blog.

Subscribe to my newsletter

I send out a newsletter every week, usually on Thursdays, that's it!

You'll get two emails initially—a confirmation link to verify your subscription, followed by a welcome message. Thanks for joining!

You can read all of my previous issues here

Related Posts.