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
| Key | Default | What it controls |
|---|---|---|
title | none | The name in the navbar and in the SEO title |
logo | none | A wordmark replacing the icon-and-name pair — see below |
version | none | The badge beside it |
organization | none | Who publishes the site, for schema.org — see below |
locales | all seven | Which of the layer's languages this site serves |
breadcrumb | true | false drops the trail above the page title |
pageIcon | none | Icon for any page whose frontmatter sets none — a section's own wins |
packageManagers | pnpm, npm, yarn, bun | Which managers a command block offers, in that order |
requestSamples | seven of twelve | Which code samples the try-it client offers — see Request samples |
sampleLanguages | none | Extra Shiki grammars, for x-codeSamples in a language no sample names |
poweredBy | on | false 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.
| Key | Default | What it controls |
|---|---|---|
copy.models | ChatGPT, Claude | Model hand-off targets (label, icon, URL prefix), in menu order |
contributors.avatarUrl | GitHub avatar template | Avatar URL; {username} is replaced for each contributor |
toc.depth | 3 | Deepest generated heading included in the contents list |
toc.scrollOffset | 96 | Pixels below the viewport top at which a heading becomes active |
search.fuzzy | 0.35, 3, 20 | Fuse threshold, minimum match length and result limit |
search.recentPages | 5 | Recently visited pages; 0 hides the list |
openapi.exampleDepth | 6 | Maximum nesting while deriving an OpenAPI example |
openapi.schemaDepth | 8 | Maximum nesting while rendering an OpenAPI schema |
shortcuts.singleCharacter | true | Whether 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.track | none | A 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.
logo
| Field | Type | Notes |
|---|---|---|
src | string | The mark. Unset leaves the generic icon beside title |
srcDark | string | Swapped in by CSS under the dark class, not by script |
alt | text | Falls 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.
| Field | Type | Notes |
|---|---|---|
name | text | The publisher's name. Nothing is published without it |
url | string | Their site. Falls back to site.url |
logo | string | Absolute, 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.
| Field | Type | Notes |
|---|---|---|
text | text | The sentence. A literal, an i18n key or a per-locale record |
link | link | One call to action. More than one belongs on the page it links to |
placement | above-header, below-header or above-content | Where it is drawn. Defaults to above-header |
startsAt | string | When it begins showing — anything Date parses |
endsAt | string | When it stops |
id | string | The dismissal identity. Derived from the text and link when unset |
Where they are drawn
| Placement | Where |
|---|---|
above-header | Full width at the browser edge, above the navbar — the default |
below-header | Full width under the navbar, above the sidebars and the page |
above-content | In 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
textandlinkat 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.
Navigation and links
A section entry is a link plus one field of its own:
| Field | Type | Notes |
|---|---|---|
pageIcon | string | Icon 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.
| Key | Default | What it controls |
|---|---|---|
navigation | one entry | Navbar links; an entry with children becomes a dropdown |
sections | empty | The second navbar row — the top-level parts of the docs |
links | empty | Icon links on the right of the navbar |
aside | title only | title and links under the table of contents |
footer | empty | copyright and legal |
landing | one action | The 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.
| Key | Default | What it draws |
|---|---|---|
badge | none | The pill above the headline — text, or a badge object |
headline | none | The h1; falls back to title |
description | none | The paragraph under it, and the page's meta description |
actions | one, "Read the docs" | The hero buttons; an action with no to resolves to the first section |
command | none | A copyable install command under the buttons |
preview | none | A page of this site, embedded in a browser window |
features | empty | The 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 field | Type | Notes |
|---|---|---|
label | text | Required. {version} is replaced with the site's version |
icon | string | Any Iconify name |
variant | string | default, secondary, outline, success, destructive |
to | string | Makes the whole pill a link |
external | boolean | The 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 field | Type | Notes |
|---|---|---|
to | string | The page to embed; defaults to the first section |
height | string | Any CSS length. Default 32rem, 24rem under sm |
src | string | A screenshot INSTEAD of the live page |
srcDark | string | Serves dark mode; without it src serves both |
alt | text | The image's alt text, and the frame's accessible name |
The live frame loads the application a second time. A site that would rather not
pay for that sets src and gets the same window around a still image.
Link shape
| Field | Type | Notes |
|---|---|---|
label | text | Required |
to | string | A documentation path is localised; a URL is not |
icon | string | Any Iconify name, e.g. lucide:rocket |
description | text | Shown in a navbar dropdown |
external | boolean | The new tab and the arrow — never the routing |
children | DuxtLink[] | Turns a navbar entry into a dropdown |
variant | button variant | landing.actions only |
Every field marked text takes a literal, an i18n key or a per-locale record — see Localisation.
Sources
| Key | Read at | What it controls |
|---|---|---|
sources | build time | The documentation sources |
sourceOptions | build time | How those sources become URL prefixes |
versions | runtime | Overrides 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
| Key | Written by | Holds |
|---|---|---|
resolvedSources | the build | The manifest: which collection serves which prefix |
layerVersion | the build | duxt's own version, for the footer |
layerRepository | the build | duxt's own repository, for the footer |
Setting any of the three by hand is overwritten on the next build.