add semantic search nextjs

Add Semantic Search to a Next.js App Router Project

Next.js developers reach for Algolia because setup is fast — paste a key, call a hook, done. Then the bill arrives. At 500,000 monthly searches with 50,000 records, Algolia Grow costs around $245/month. altor-vec eliminates the backend entirely: build the index at deploy time, serve it from /public, and run all search in the user's browser with no per-query cost and no network roundtrip.

Install: npm install altor-vec @huggingface/transformers tsx

How the architecture works

The pattern has three phases that map to different parts of your Next.js project:

  1. Build time: a Node script reads your content, generates float vector embeddings, and writes two files — search-index.bin and search-metadata.json — to your /public directory
  2. Deploy: Next.js copies /public to the CDN. Both files are served as static assets with no compute cost
  3. Runtime: a Client Component fetches both files once, loads the WASM engine, and handles all search queries locally with no network roundtrip

WASM in App Router: altor-vec's WASM module cannot initialize during server rendering. All code that calls init(), new WasmSearchEngine(), or engine.search() must be inside a 'use client' component and run in a useEffect or after user interaction.

Step 1: Write the index build script

Create scripts/build-search-index.ts. This script runs at build time in Node — not in the browser — so you can use the file system and any Node packages.

// scripts/build-search-index.ts
import fs from 'node:fs/promises';
import path from 'node:path';
import { glob } from 'glob';
import { pipeline } from '@huggingface/transformers';
import init, { WasmSearchEngine } from 'altor-vec';

interface DocMetadata {
  id: number;
  title: string;
  excerpt: string;
  url: string;
  section?: string;
}

async function buildIndex() {
  await init();
  const embed = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');

  // Adjust this glob to match your content structure
  const mdFiles = await glob('content/**/*.mdx', { cwd: process.cwd() });
  const vectors: number[] = [];
  const metadata: DocMetadata[] = [];

  for (let i = 0; i < mdFiles.length; i++) {
    const filePath = mdFiles[i];
    const raw = await fs.readFile(filePath, 'utf8');

    // Strip frontmatter and extract title
    const frontmatterMatch = raw.match(/^---\n([\s\S]*?)\n---/);
    const title = frontmatterMatch
      ? (frontmatterMatch[1].match(/title:\s*["']?(.+?)["']?\n/) ?? [])[1] ?? path.basename(filePath, '.mdx')
      : path.basename(filePath, '.mdx');

    const body = raw.replace(/^---[\s\S]*?---\n/, '').replace(/#{1,6}\s/g, '');
    const textToEmbed = `${title}\n${body.slice(0, 800)}`;

    const out = await embed(textToEmbed, { pooling: 'mean', normalize: true });
    vectors.push(...Array.from(out.data as Float32Array));

    metadata.push({
      id: i,
      title,
      excerpt: body.slice(0, 200).trim(),
      url: '/' + filePath.replace(/\.mdx?$/, '').replace(/^content\//, ''),
    });

    if (i % 10 === 0) process.stdout.write(`\rIndexing ${i + 1}/${mdFiles.length}...`);
  }

  const dim = 384;
  const flat = new Float32Array(vectors);
  const engine = WasmSearchEngine.from_vectors(flat, dim, 16, 200, 50);

  await fs.mkdir('./public', { recursive: true });
  await fs.writeFile('./public/search-index.bin', Buffer.from(engine.to_bytes()));
  await fs.writeFile('./public/search-metadata.json', JSON.stringify(metadata));

  console.log(`\nIndexed ${metadata.length} documents`);
}

buildIndex().catch(console.error);

Step 2: Hook it into the build pipeline

// package.json
{
  "scripts": {
    "build": "next build",
    "postbuild": "tsx scripts/build-search-index.ts"
  }
}

The postbuild hook runs automatically after next build completes — both locally and in Vercel's build environment. The two output files land in /public and are included in the deployment.

For faster local development, add a dedicated command so you don't rebuild the index on every next dev restart:

// package.json
{
  "scripts": {
    "build:search": "tsx scripts/build-search-index.ts",
    "dev": "next dev",
    "build": "next build",
    "postbuild": "npm run build:search"
  }
}

Step 3: Create the search engine module

Create lib/search.ts to encapsulate initialization and querying. This module is imported only from Client Components.

// lib/search.ts
'use client';

import type { WasmSearchEngine } from 'altor-vec';

export interface SearchResult {
  id: number;
  title: string;
  excerpt: string;
  url: string;
  score: number;
}

let engine: WasmSearchEngine | null = null;
let metadata: Omit<SearchResult, 'score'>[] = [];
let initPromise: Promise<void> | null = null;

export async function initSearch(): Promise<void> {
  if (engine) return;
  if (initPromise) return initPromise;

  initPromise = (async () => {
    const { default: init, WasmSearchEngine } = await import('altor-vec');
    await init();

    const [indexBuf, meta] = await Promise.all([
      fetch('/search-index.bin').then(r => r.arrayBuffer()),
      fetch('/search-metadata.json').then(r => r.json()),
    ]);

    engine = new WasmSearchEngine(new Uint8Array(indexBuf));
    metadata = meta;
  })();

  return initPromise;
}

export async function search(query: string, topK = 6): Promise<SearchResult[]> {
  await initSearch();
  if (!engine || !query.trim()) return [];

  // Embed query using the same model as the index build
  const { pipeline } = await import('@huggingface/transformers');
  const embedder = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
  const out = await embedder(query, { pooling: 'mean', normalize: true });
  const vec = new Float32Array(out.data as Float32Array);

  const hits = JSON.parse(engine.search(vec, topK)) as [number, number][];
  return hits.map(([id, distance]) => ({
    ...metadata[id],
    score: 1 - distance,
  }));
}

Step 4: Build the SearchModal component

// components/SearchModal.tsx
'use client';

import { useEffect, useRef, useState, useCallback } from 'react';
import { initSearch, search, type SearchResult } from '@/lib/search';

export function SearchModal() {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<SearchResult[]>([]);
  const [loading, setLoading] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);
  const debounceRef = useRef<ReturnType<typeof setTimeout>>();

  // Preload search engine on mount
  useEffect(() => { initSearch(); }, []);

  // Keyboard shortcut: Cmd+K / Ctrl+K
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
        e.preventDefault();
        setOpen(o => !o);
      }
      if (e.key === 'Escape') setOpen(false);
    };
    window.addEventListener('keydown', handler);
    return () => window.removeEventListener('keydown', handler);
  }, []);

  useEffect(() => {
    if (open) setTimeout(() => inputRef.current?.focus(), 50);
  }, [open]);

  const handleQuery = useCallback((value: string) => {
    setQuery(value);
    clearTimeout(debounceRef.current);
    if (!value.trim()) { setResults([]); return; }
    setLoading(true);
    debounceRef.current = setTimeout(async () => {
      const hits = await search(value);
      setResults(hits);
      setLoading(false);
    }, 200);
  }, []);

  if (!open) {
    return (
      <button
        onClick={() => setOpen(true)}
        className="search-trigger"
        aria-label="Open search (Cmd+K)"
      >
        Search ⌘K
      </button>
    );
  }

  return (
    <div className="search-overlay" onClick={(e) => e.target === e.currentTarget && setOpen(false)}>
      <div className="search-modal">
        <input
          ref={inputRef}
          value={query}
          onChange={e => handleQuery(e.target.value)}
          placeholder="Search documentation..."
          className="search-input"
        />
        {loading && <p className="search-hint">Searching…</p>}
        {!loading && results.length === 0 && query && (
          <p className="search-hint">No results for "{query}"</p>
        )}
        <ul className="search-results">
          {results.map(r => (
            <li key={r.id}>
              <a href={r.url} onClick={() => setOpen(false)}>
                <strong>{r.title}</strong>
                <span>{r.excerpt}</span>
              </a>
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

Step 5: Add SearchModal to your layout

// app/layout.tsx
import { SearchModal } from '@/components/SearchModal';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <header>
          <nav>{/* your nav */}</nav>
          <SearchModal />
        </header>
        {children}
      </body>
    </html>
  );
}

Next.js-specific pitfalls

WASM cannot run in Server Components

Any file that imports from altor-vec must be a Client Component ('use client' at the top). If you import it in a Server Component or in shared utility code that gets bundled for the server, you'll see a WebAssembly is not defined error. Keep all altor-vec code in client-only files.

Dynamic import for embedding model

The @huggingface/transformers pipeline loads a ~23MB model file on first call. To avoid blocking the initial page load, initialize it lazily — only when the user opens the search modal or types their first query. The code in lib/search.ts above handles this correctly via the initPromise pattern.

Turbopack compatibility

As of Next.js 15, Turbopack handles WASM imports differently from webpack. If you see WASM loading errors with next dev --turbo, fall back to next dev (without Turbopack) for local development while this stabilizes. Production builds use webpack and work correctly.

Index file cache headers

Add long-lived cache headers for the index files in next.config.ts. The files change only when content changes, so cache them aggressively:

// next.config.ts
const nextConfig = {
  async headers() {
    return [
      {
        source: '/search-:file*',
        headers: [
          { key: 'Cache-Control', value: 'public, max-age=86400, stale-while-revalidate=3600' },
        ],
      },
    ];
  },
};
export default nextConfig;

Moving embeddings to a Web Worker

Generating an embedding from a query string takes 20-100ms depending on device. This runs on the main thread by default, which can cause visible input lag. Move it to a Web Worker:

// public/embed-worker.js
import { pipeline } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3/dist/transformers.min.js';

let embedder;

self.onmessage = async ({ data: { query, id } }) => {
  if (!embedder) {
    embedder = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
  }
  const out = await embedder(query, { pooling: 'mean', normalize: true });
  self.postMessage({ id, vector: Array.from(out.data) });
};
// In your search component
const workerRef = useRef<Worker>();

useEffect(() => {
  workerRef.current = new Worker('/embed-worker.js', { type: 'module' });
  workerRef.current.onmessage = ({ data: { vector } }) => {
    const hits = JSON.parse(engine.search(new Float32Array(vector), 6));
    setResults(hits.map(([id, dist]) => ({ ...metadata[id], score: 1 - dist })));
    setLoading(false);
  };
  return () => workerRef.current?.terminate();
}, []);

function handleQuery(query: string) {
  setLoading(true);
  workerRef.current?.postMessage({ query, id: Date.now() });
}

FAQ

Does altor-vec work with Next.js SSR?

The WASM module runs client-side only. Place all altor-vec code in 'use client' components. The search index files are static assets fetched at runtime. Nothing runs on the server, which means search works on any Next.js deployment including static export (next export).

How do I rebuild the index when content changes?

The postbuild script in package.json runs automatically after next build. In CI pipelines (Vercel, GitHub Actions), this runs on every deployment. For local development, run npm run build:search manually when content changes significantly.

Can I deploy this to Vercel?

Yes. The index files are static assets in /public. Vercel's CDN serves them. WASM runs in the user's browser. The postbuild script runs during Vercel's build process. No serverless functions are involved in search queries.

Add to your Next.js project: npm install altor-vec · GitHub