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](/getting-started/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](/reference/openapi/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](/concepts/keyboard-shortcuts) |
| `analytics.track` | **none** | A function handed one event per reader interaction. Unset, duxt reports nothing — see [Send analytics events](/guides/send-analytics-events) |

The nested form keeps unrelated behaviour independently replaceable:

```ts [app/app.config.ts]
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](/guides/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.

```ts [app/app.config.ts]
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 `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.

## 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                                                |

```ts [app/app.config.ts]
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     |

::callout{type="tip" title="A screenshot is the cheaper option"}
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](/concepts/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](/reference/sources). `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:

```ts
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](/reference/changelog), 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.