duxt emits [one build-time hook](/reference/hooks), 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.

::callout{type="info" title="The service is yours, not duxt's"}
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](/reference/hooks).
::

## 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.

::package-managers{command="add @kirchdev/duxt-typesense"}
::

```ts [nuxt.config.ts]
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

::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.

   ::package-managers{command="add -D typesense"}
   ::

2. **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:

   ```ts [server/typesense-schema.ts]
   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](/reference/hooks) — 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.

3. **Write the module that listens.** A Nuxt module in your own site is the
   whole integration — `defineNuxtModule`, one hook, one import:

   ```ts [modules/typesense.ts]
   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.

4. **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:

   ```ts [modules/typesense.ts]
   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.

   ::callout{type="tip" title="Or swap an alias instead"}
   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.
   ::

5. **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:

   ```ts
   // `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(' && ')
   });
   ```

6. **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](/reference/hooks) 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.

```ts [nuxt.config.ts]
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](/reference/hooks).

## 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