Hooks
The one build-time hook duxt emits, what it hands you, and why there is only one.
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.
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.
duxt:search:records
(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.
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,nameand 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 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:
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:
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 |
| What a component draws | Shadowing it, or a slot where one exists |
| What the site says about itself | app.config's duxt key — Configuration |
| A new kind of generated section | sectionTypes — Sources |
| What one page draws around itself | Frontmatter — Frontmatter |
| Where the site's text is indexed | This 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.