Skip to content
duxt

Configuration

Every key under duxt in app.config.ts — what it controls, and when it is read.

Everything below lives under duxt in your app/app.config.ts. How the merge works, and why arrays replace rather than append, is in Configuration.

Site

KeyDefaultWhat it controls
titlenoneThe name in the navbar and in the SEO title
logononeA wordmark replacing the icon-and-name pair — see below
versionnoneThe badge beside it
organizationnoneWho publishes the site, for schema.org — see below
localesall sevenWhich of the layer's languages this site serves
breadcrumbtruefalse drops the trail above the page title
pageIconnoneIcon for any page whose frontmatter sets none — a section's own wins
packageManagerspnpm, npm, yarn, bunWhich managers a command block offers, in that order
requestSamplesseven of twelveWhich code samples the try-it client offers — see Request samples
sampleLanguagesnoneExtra Shiki grammars, for x-codeSamples in a language no sample names
poweredByonfalse drops the "Powered by duxt" line from the footer

Behaviour

These defaults preserve the layer’s current behaviour. They are configuration because only a consumer knows its model services, avatar host, document depth and keyboard collisions. Lists replace their defaults, so they can remove as well as add entries.

KeyDefaultWhat it controls
copy.modelsChatGPT, ClaudeModel hand-off targets (label, icon, URL prefix), in menu order
contributors.avatarUrlGitHub avatar templateAvatar URL; {username} is replaced for each contributor
toc.depth3Deepest generated heading included in the contents list
toc.scrollOffset96Pixels below the viewport top at which a heading becomes active
search.fuzzy0.35, 3, 20Fuse threshold, minimum match length and result limit
search.recentPages5Recently visited pages; 0 hides the list
openapi.exampleDepth6Maximum nesting while deriving an OpenAPI example
openapi.schemaDepth8Maximum nesting while rendering an OpenAPI schema
shortcuts.singleCharactertrueWhether the unmodified global keys ?, [ and ] are bound at all; false leaves only ⌘/Ctrl+K, and with ? gone the header's keyboard button is the sheet's only entry point — see Keyboard shortcuts
analytics.tracknoneA function handed one event per reader interaction. Unset, duxt reports nothing — see Send analytics events

The nested form keeps unrelated behaviour independently replaceable:

export default defineAppConfig({
  duxt: {
    copy: {
      // Lists replace the defaults. An empty list leaves copy/view-source but
      // offers no external model hand-off.
      models: [
        {
          label: 'My assistant',
          icon: 'simple-icons:openai',
          url: 'https://example.com/new?prompt='
        }
      ]
    },
    contributors: {
      avatarUrl: 'https://github.com/{username}.png?size=96'
    },
    toc: { depth: 4, scrollOffset: 112 },
    search: {
      fuzzy: { threshold: 0.3, minMatchCharLength: 3, limit: 15 },
      recentPages: 8
    },
    openapi: { exampleDepth: 6, schemaDepth: 8 },
    shortcuts: { singleCharacter: false }
  }
})

copy.models is the only list in this block and therefore replaces the two default destinations rather than appending to them. search.recentPages: 0 hides the recent list and retains no new entries. Fuzzy tuning changes only the near-miss pass used after exact Content search returns nothing. OpenAPI's two depths are separate because deriving a compact example and rendering a schema tree have different useful limits.

analytics.track cannot be represented in JSON and is intentionally a function in app.config.ts. duxt calls it; it does not choose a provider, endpoint, consent model or storage policy for the site.

Each source may also set exclude.drafts and exclude.partials; their defaults are **/*.draft.md and **/_partials/**. This is per source, so one imported repository’s conventions do not hide another’s pages.

Toast duration intentionally remains fixed at 4000 ms. It is behaviour of the layer-owned toast component; override that component when a site needs a different notification design.

FieldTypeNotes
srcstringThe mark. Unset leaves the generic icon beside title
srcDarkstringSwapped in by CSS under the dark class, not by script
alttextFalls back to title

Setting one is Brand your site.

organization

The Organization node search engines and AI summaries read to name a source. Absent until you set it: duxt renders somebody else's documentation and does not guess whose.

FieldTypeNotes
nametextThe publisher's name. Nothing is published without it
urlstringTheir site. Falls back to site.url
logostringAbsolute, or a path from the site root; square and 112px or more

Announcements

A notice across the whole site — a release, a migration window, a maintenance slot. Off until you write one: with announcements empty nothing is drawn at all, and no space is held open for it.

announcements: [
  {
    text: { 'en-GB': 'Version 2 is out.', 'de-DE': 'Version 2 ist da.' },
    link: { label: 'Read the release notes', to: '/releases/v2' }
  },
  {
    id: 'march-maintenance',
    text: 'Scheduled maintenance on Sunday from 09:00 UTC.',
    placement: 'below-header',
    startsAt: '2026-03-01T09:00:00Z',
    endsAt: '2026-03-08T09:00:00Z'
  }
],
announcementOptions: { maxVisible: 1 }

A list, in the order it is drawn: a release and an outage are two announcements, and one of them ending should not take the other with it.

FieldTypeNotes
texttextThe sentence. A literal, an i18n key or a per-locale record
linklinkOne call to action. More than one belongs on the page it links to
placementabove-header, below-header or above-contentWhere it is drawn. Defaults to above-header
startsAtstringWhen it begins showing — anything Date parses
endsAtstringWhen it stops
idstringThe dismissal identity. Derived from the text and link when unset

Where they are drawn

PlacementWhere
above-headerFull width at the browser edge, above the navbar — the default
below-headerFull width under the navbar, above the sidebars and the page
above-contentIn the document column, with the version and translation notices

above-content is the document column's, so it appears on documentation pages and not on a landing page, which has no such column.

announcementOptions.maxVisible caps how many a placement shows at once. A number caps every placement at that many — one notice above the header and one below it, not one between them — an object caps each separately ({ 'above-header': 1 }), and null, an omitted key or no maxVisible at all means unlimited. The cap is on what is drawn: closing the notice at the top of a capped placement promotes the next one rather than retiring it unseen.

When they are shown

startsAt and endsAt are read in the browser, against the reader's own clock — so an announcement opens and closes on time without a deployment, and without the page being reloaded. A window is half-open: the notice shows from startsAt and until endsAt.

Write an offset where the moment matters (2026-03-01T09:00:00Z). A date with no time zone is local to whoever is reading, which is usually what a maintenance window means and occasionally is not. A date neither form parses is treated as if it were not there, so a typo leaves the notice showing rather than silently swallowing it.

Dismissal

A reader closes an announcement and it stays closed — across navigation, across a reload, and across a change of language. What is remembered is the announcement's identity:

  • with an id, that string. It is locale-independent and yours to change: change it and every reader is shown the announcement again.
  • without one, the identity is derived from the announcement's content — every locale of its text and link at once. Rewriting the sentence is a new announcement; tidying the order of the locale keys is not, and neither is moving it to another placement or extending its window.

Set an id where the wording may be corrected without the notice counting as new. Leave it off where any edit should reach the readers who dismissed the previous version.

The dismissals live in the reader's own browser (localStorage), so they travel with neither the reader nor a server, and a key whose announcement has left the config is dropped rather than kept forever.

Because both halves of this — the clock and the dismissals — belong to the browser, the strip is drawn after hydration rather than prerendered into the page. A notice that expired overnight is gone on the next view without a rebuild, and a dismissed one never flashes back.

A section entry is a link plus one field of its own:

FieldTypeNotes
pageIconstringIcon for pages in this section that set none. Overrides duxt.pageIcon; a page's own frontmatter still wins

Useful where pages cannot carry an icon at all — an ADR's frontmatter is fixed at title, description, status and date, so the decision log otherwise renders without one.

KeyDefaultWhat it controls
navigationone entryNavbar links; an entry with children becomes a dropdown
sectionsemptyThe second navbar row — the top-level parts of the docs
linksemptyIcon links on the right of the navbar
asidetitle onlytitle and links under the table of contents
footeremptycopyright and legal
landingone actionThe page at /

Most of these ship empty, for two different reasons. links, aside.links, footer.legal, title, version and the landing copy name a specific project — a repository, a community, a name, a release, a sentence about what it does — and belong to whoever runs the site. sections names the parts of a documentation tree, and the tree is yours: four tabs pointing at folder names the layer guessed would lead nowhere. Until you set it, the row does not render and the sidebar shows the whole tree, which is the right shape for docs that are not split into sections.

One row per source. An entry belongs to the source whose URL prefix it lies under, and the row shows the entries of the source the reader is in — so a site with documentation at the root and something else at /demo writes one sections list and each area draws its own part of it. A generated section under an area (/demo/openapi) belongs to that area rather than being one of its own. A site with a single source has one area, every entry is inside it, and the row is exactly the list as written.

Landing

The page at / draws three bands, and each one is absent until its key is set.

KeyDefaultWhat it draws
badgenoneThe pill above the headline — text, or a badge object
headlinenoneThe h1; falls back to title
descriptionnoneThe paragraph under it, and the page's meta description
actionsone, "Read the docs"The hero buttons; an action with no to resolves to the first section
commandnoneA copyable install command under the buttons
previewnoneA page of this site, embedded in a browser window
featuresemptyThe card grid

command is a plain string, not text: a shell command is the same in every language, and one translated by mistake is one that does not run. The layer ships none — it does not know what your project is called, the same reason links is empty.

There is no closing call to action, deliberately. The hero's buttons are the call; repeating them under a card grid asks a reader who has just arrived to decide twice.

badge ships empty for the same reason as links: a pill above the headline says something about the state of a project — "beta", "v2 is out" — and the layer knows nothing about the state of yours. A string is the short form; the object adds an icon, a colour and a destination.

badge fieldTypeNotes
labeltextRequired. {version} is replaced with the site's version
iconstringAny Iconify name
variantstringdefault, secondary, outline, success, destructive
tostringMakes the whole pill a link
externalbooleanThe new tab
badge: {
  label: '{version} released',
  icon: 'lucide:rocket',
  variant: 'success',
  to: 'https://github.com/acme/sdk/releases/latest',
  external: true
}

preview is a live window, not a picture. It embeds a page of this site in a browser frame the reader can scroll, navigate and switch the theme of without leaving the landing page. The frame mounts only once the band scrolls into view, and never during server rendering — an iframe in the initial HTML is a second full page load competing with the first.

preview fieldTypeNotes
tostringThe page to embed; defaults to the first section
heightstringAny CSS length. Default 32rem, 24rem under sm
srcstringA screenshot INSTEAD of the live page
srcDarkstringServes dark mode; without it src serves both
alttextThe image's alt text, and the frame's accessible name
FieldTypeNotes
labeltextRequired
tostringA documentation path is localised; a URL is not
iconstringAny Iconify name, e.g. lucide:rocket
descriptiontextShown in a navbar dropdown
externalbooleanThe new tab and the arrow — never the routing
childrenDuxtLink[]Turns a navbar entry into a dropdown
variantbutton variantlanding.actions only

Every field marked text takes a literal, an i18n key or a per-locale record — see Localisation.

Sources

KeyRead atWhat it controls
sourcesbuild timeThe documentation sources
sourceOptionsbuild timeHow those sources become URL prefixes
versionsruntimeOverrides the derived versions, when they need labels or descriptions

Both source keys have their own page. locales is the third build-time key: changing any of the three needs a rebuild.

The feed

/rss.xml is empty until feed.path names a section:

feed: { path: '/changelog', title: 'my project — releases' }

Off by default on purpose. A feed is a list of things that happened, and a reference page being edited is not an event — a site publishing every page edit as an item teaches its readers to unsubscribe. Items are ordered by a page's own date, falling back to the last commit that touched it, and only the default version of each source contributes, so a versioned changelog does not repeat every entry once per version.

The section it is usually pointed at is a release history, whose pages carry a date each.

Generated, not written

KeyWritten byHolds
resolvedSourcesthe buildThe manifest: which collection serves which prefix
layerVersionthe buildduxt's own version, for the footer
layerRepositorythe buildduxt's own repository, for the footer

Setting any of the three by hand is overwritten on the next build.

Was this page helpful?