duxt emits **one** hook. It is a Nuxt build hook, it fires once per build, and it
hands you every page of the site as flat search records.

```ts [modules/my-search.ts]
import { defineNuxtModule } from '@nuxt/kit';
import type { DuxtSearchRecord } from '@kirchdev/duxt/search-records';

export default defineNuxtModule({
  setup(_options, nuxt) {
    nuxt.hook('duxt:search:records', async (records) => {
      await upload(records);
    });
  }
});
```

A worked example of one — the collection schema, the upsert, the removal of
what is gone, and the key that must never reach a page — is in
[Send your pages to Typesense](/add-ons/typesense).

## `duxt:search:records`

```ts
(records: readonly DuxtSearchRecord[]) => void | Promise<void>
```

**When it fires.** After Content has parsed every source, every version, every
language and every generated section — the remote repositories it downloaded
included — and **before** Nitro prerenders or bundles anything. A listener may
still write a file into `public/` and have it land in the output.

**What a record is.** One page, plus one more per heading on it.

| Field     | Type     | What it holds                                                             |
| :-------- | :------- | :------------------------------------------------------------------------ |
| `id`      | `string` | The record's identity, and a valid primary key in every provider          |
| `url`     | `string` | Where a reader lands — locale segment and anchor included                  |
| `title`   | `string` | The heading this record covers, or the page title for the first           |
| `content` | `string` | The words under that heading, markup removed and whitespace collapsed     |
| `source`  | `string` | Which source it came from, as URL segments minus the version              |
| `version` | `string` | The version label, where the source has versions — absent where it has not |
| `locale`  | `string` | The language the **content** is in                                        |

`id` is a hex digest rather than the URL, because Meilisearch accepts a document
id of `A-Za-z0-9_-` only. It is derived from `url`, so it is the same on every
machine and stays the same as long as the page stays where it is — which is what
lets a provider upsert instead of rebuilding.

`source` is an **identity, not an address**: `/` for the documentation served at
the root, `/harbour` for a second repository, `/harbour/api` for a generated
section inside it. The version is deliberately not part of it, so a facet built
on `source` survives a release and `version` beside it is what narrows to one.

`locale` is the language the content is written in, not the interface language a
reader is browsing in. A site whose sources are untranslated gets **one** set of
records even when it serves seven locales — the same paragraph indexed seven
times is one answer repeated.

::callout{type="warning" title="The payload is frozen"}
Build or upload your own index from it. Do not rewrite a record, and do not sort
the array in place: every other listener is handed the same object, and an edit
made on the way past would be inherited by the next one with nothing saying so.
::

### What it deliberately does not carry

- **No excerpt.** A snippet is a decision about a result list, and the result
  list is yours.
- **No collection name.** That is duxt's own index bookkeeping, and a document
  keyed on it breaks the day a source gains a language.
- **No display label.** `label`, `name` and the version caption are prose for a
  reader, translated per locale. A record is not read by a reader.

### What is left out of the index

A page that wrote [`search: false`](/reference/frontmatter) produces no records
at all — the same opt-out the search dialog, the MCP `search_docs` tool and both
llms indexes already honour. So does a translated source in a language
`duxt.locales` does not serve, because indexing it would offer a reader a URL
that 404s.

Where a site routes **two regions of one language** over one tree — `pt-PT` and
`pt-BR` both reading a single `pt/` folder — the records address the first. The
two URLs carry identical words, so indexing both would double the index to say
the same thing twice; a provider that wants to send a reader to their own
region swaps the segment, which duxt cannot do because it does not know who is
searching.

And when nobody is listening, duxt builds no payload at all, so a site with no
provider pays nothing for the hook existing.

## Why there is only one

A distributable duxt extension is **a Nuxt layer**, not a plugin object:

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

That already carries components, `app.config` defaults, `content.config.ts`
collections, server routes, MCP tools and a module of its own, in one package
with a version of its own. A `duxt.plugins` API would reimplement layer
resolution inside a layer — and a registry a plugin writes into at import time
walks into the wall that shaped [`sectionTypes`](/reference/configuration):
`content.config.ts` and duxt's module are loaded by **different loaders**, so a
mutable registry each wrote into would be two registries. A Nuxt build hook does
not have that problem, because only the module half ever sees it.

So the surface stays small on purpose, and each of the other ways in is still
the right answer to its own question:

| You want to change                   | Use                                                              |
| :----------------------------------- | :--------------------------------------------------------------- |
| A colour, a font, a spacing          | A CSS token — [Override the theme](/guides/override-the-theme)    |
| What a component draws               | Shadowing it, or a slot where one exists                         |
| What the site says about itself      | `app.config`'s `duxt` key — [Configuration](/reference/configuration) |
| A new kind of generated section      | `sectionTypes` — [Sources](/reference/sources)                    |
| What one page draws around itself    | Frontmatter — [Frontmatter](/reference/frontmatter)               |
| Where the site's text is indexed     | This hook                                                        |

::callout{type="tip" title="Asking for a second hook"}
A hook name is public surface: renaming it is a breaking change, and an unused
one can never be removed. So a new one is added when a consumer exists that
cannot be written without it — not in advance, and not because a seam looks like
it might be wanted one day.
::