Skip to content
duxt

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.

FieldTypeWhat it holds
idstringThe record's identity, and a valid primary key in every provider
urlstringWhere a reader lands — locale segment and anchor included
titlestringThe heading this record covers, or the page title for the first
contentstringThe words under that heading, markup removed and whitespace collapsed
sourcestringWhich source it came from, as URL segments minus the version
versionstringThe version label, where the source has versions — absent where it has not
localestringThe 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.

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 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 changeUse
A colour, a font, a spacingA CSS token — Override the theme
What a component drawsShadowing it, or a slot where one exists
What the site says about itselfapp.config's duxt key — Configuration
A new kind of generated sectionsectionTypes — Sources
What one page draws around itselfFrontmatter — Frontmatter
Where the site's text is indexedThis hook
Was this page helpful?