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.
duxt ships no Typesense SDK, credentials or service configuration. The optional companion layer owns that integration; you own the service and its keys. Without a provider or a custom listener, duxt builds no payload at all.
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-typesenseexport 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
- 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- 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.
- 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.
- 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.
Import into a collection named for the build, then point a Typesense alias at it and drop the previous one. Each build creates its own collection, so neither the existence check nor the batched delete is needed, and readers never see a half-written index — at the cost of reindexing everything on every build rather than only what moved.
- 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(' && ')
});
- 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:
| Property | What it buys the sync |
|---|---|
id is derived from url | A stable primary key, so upsert is enough and a rebuild is never needed |
| The payload is the complete set | Removal is a diff against one build, with no crawl and no bookkeeping |
| duxt does not fire on an empty parse | A build that parsed nothing can never tell you to empty the index |
| Order is deterministic | Two 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,versionandlocale - 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