Skip to content
duxt

Typesense

Consume duxt's one build hook into a Typesense collection — every source, version and language, upserted by a stable id.

duxt emits one build-time hook, and it hands over every page of every source, version and language as flat records. A site that already runs Typesense turns those records into a collection of its own — without re-crawling the rendered site, and without reimplementing duxt's source, version and locale logic.

Use the companion layer

For the packaged integration, install @kirchdev/duxt-typesense and extend it after duxt. It synchronizes the complete set through a staging collection and alias swap, removes the previous managed collection, and connects the existing search dialog with a search-only key. The steps below remain a recipe for custom synchronization.

pnpm add @kirchdev/duxt-typesense
export default defineNuxtConfig({
  extends: ['@kirchdev/duxt', '@kirchdev/duxt-typesense'],
  duxtTypesense: {
    endpoint: process.env.TYPESENSE_ENDPOINT,
    collection: 'docs',
    adminKey: process.env.TYPESENSE_ADMIN_KEY,
    searchKey: process.env.TYPESENSE_SEARCH_KEY
  }
})

Keep the admin key in build-only configuration. searchKey must have only documents:search for this alias, and Typesense must allow browser requests from your site.

Steps

  1. Install the client in your site. It is a build-time dependency: the sync runs in Node during nuxt build, never in the browser.
pnpm add -D typesense
  1. Declare the collection. Every field a duxt record carries maps onto one Typesense field, and the two provenance fields are what a reader filters by:
import type { CollectionCreateSchema } from 'typesense';

export const docs: CollectionCreateSchema = {
  name: 'docs',
  fields: [
    { name: 'url', type: 'string' },
    { name: 'title', type: 'string' },
    { name: 'content', type: 'string' },
    { name: 'source', type: 'string', facet: true },
    { name: 'version', type: 'string', facet: true, optional: true },
    { name: 'locale', type: 'string', facet: true }
  ]
};

Typed as CollectionCreateSchema, because step 4 hands this very object to Typesense to create the collection with.

id is not in the list on purpose: Typesense already treats id as the document's primary key, and duxt's id is a hex digest of the URL — valid as a key, identical on every machine, and unchanged for as long as the page stays where it is. That is what lets the sync upsert instead of rebuild.

version is optional, because a source with only one version carries none. source deliberately has the version stripped out of it, so a facet built on it survives a release and version beside it is what narrows to one.

  1. Write the module that listens. A Nuxt module in your own site is the whole integration — defineNuxtModule, one hook, one import:
import { defineNuxtModule } from '@nuxt/kit';
import { Client } from 'typesense';
import type { DuxtSearchRecord } from '@kirchdev/duxt/search-records';
import { docs } from '../server/typesense-schema';

export default defineNuxtModule({
  meta: { name: 'typesense-sync' },
  setup(_options, nuxt) {
    const key = process.env.TYPESENSE_ADMIN_KEY;
    // No key is a local build, not a failure: skip rather than break it.
    if (!key) return;

    nuxt.hook('duxt:search:records', async (records) => {
      const client = new Client({
        nodes: [{ url: process.env.TYPESENSE_URL! }],
        apiKey: key
      });

      await sync(client, records);
    });
  }
});

The payload is frozen. Map it into documents of your own — never rewrite a record or sort the array in place, because every other listener is handed the same objects.

  1. Synchronise the whole set, and remove what is gone. The hook hands over the complete current record set on every build, so the set itself is the instruction — anything the collection holds that is not in it has been deleted, moved or renamed:
async function sync(client: Client, records: readonly DuxtSearchRecord[]) {
  const collection = client.collections(docs.name);

  // Typesense creates nothing implicitly: importing into a collection
  // that does not exist fails outright, so the first build makes it.
  if (!(await collection.exists())) await client.collections().create(docs);

  await collection.documents().import(
    records.map((record) => ({ ...record })),
    { action: 'upsert' }
  );

  // Everything the build did not hand over is no longer on the site.
  const kept = new Set(records.map((record) => record.id));
  const live = await collection.documents().export();

  const stale = live
    .split('\n')
    .filter(Boolean)
    .map((line) => JSON.parse(line).id as string)
    .filter((id) => !kept.has(id));

  // Delete in batches: every id goes into the filter string, and one
  // large rename would build one long enough for Typesense to reject.
  for (let index = 0; index < stale.length; index += 100) {
    const batch = stale.slice(index, index + 100).join(',');
    await collection.documents().delete({ filter_by: `id:[${batch}]` });
  }
}

The collection is created on the first run and left alone afterwards, and the stale ids are deleted in batches — Typesense takes the filter as one string, so a rename that moves a whole source would otherwise build one it rejects.

  1. Search from the browser with a search-only key. The admin key above signs writes and must never reach a page. Issue a scoped, search-only key for the client, and filter on the facets the schema declared:
// `locale` is the language the CONTENT is in, not the interface language.
// `version` is absent on an unversioned source, so the clause is added
// only when there is one — pasted in flat, it filters on `undefined`.
const filters = [`locale:${locale}`];
if (version) filters.push(`version:${version}`);

const results = await client.collections('docs').documents().search({
  q: query,
  query_by: 'title,content',
  filter_by: filters.join(' && ')
});
  1. Check one build end to end. Run a build with the key set, and confirm the collection holds a record for a page in every source, version and language the site serves — not only the default one. The first build creates the collection; every build after it upserts into the same one.

What the records already settle for you

Four properties of the payload are what make a sync this short correct at all, and all four are the hook's contract rather than this guide's advice:

PropertyWhat it buys the sync
id is derived from urlA stable primary key, so upsert is enough and a rebuild is never needed
The payload is the complete setRemoval is a diff against one build, with no crawl and no bookkeeping
duxt does not fire on an empty parseA build that parsed nothing can never tell you to empty the index
Order is deterministicTwo builds differ exactly where the site did

A page that wrote search: false, and a translation in a language the site does not route, produce no records — so neither reaches Typesense.

Making it a package

The module above is ~40 lines and belongs to one site. Once a second site needs it, the way to share it is a Nuxt layer, not a plugin: a layer carries the module, its app.config defaults and its components in one versioned package, and a consumer adds it beside duxt.

export default defineNuxtConfig({
  extends: ['@kirchdev/duxt', 'duxt-typesense']
});

That layer owns the credentials, the service configuration, the sync policy and the browser-side search UI. duxt itself gains none of them — which is the whole reason the hook is shaped the way it is.

Checklist

  • The client is a dependency of your site, not of the layer
  • The admin key lives in the build environment and never reaches a page
  • The collection is created on the first build, and facets source, version and locale
  • Records no longer handed over are removed — by a batched diff, or by an alias swap
  • A build without the key skips the sync instead of failing
  • Every source, version and language is represented in the collection
Was this page helpful?