Configuration

Set the site title, nav menu, dark mode, and search engine.

Compose is configured through a single typed object. Because it is plain TypeScript, your editor autocompletes every option and catches typos at build time.

import { defineThemeConfig } from "@compose/theme-config";

export const config = defineThemeConfig({
  title: "My Docs",
  defaultLightingMode: "auto",
  search: { enabled: true, engine: "pagefind", global: true },
  menu: [
    { name: "Docs", url: "/docs" },
    { name: "Blog", url: "/blog" },
  ],
});
TypeScript

Search is Pagefind, which needs no account and no hosted service. If you want a different engine, see Search. It is a file swap, not a config flag.

Every option is validated when the config is built. A value the theme can't use (a malformed colour, a relative siteUrl, a nonsensical limit) is ignored with a build-time warning and the built-in default is used instead, so a typo can't break your site.

Site URL

Set the absolute origin of your deployed site. RSS and canonical URLs need it, and a relative path won't do. Trailing slashes are stripped for you.

export const config = defineThemeConfig({
  siteUrl: "https://docs.acme.com",
});
TypeScript

Deployments can override it with the COMPOSE_SITE_URL environment variable, which is what you want when one repository is published to more than one origin: a staging host, or the same theme demoed on two subdomains. It takes precedence over the configured value; anything that isn't an absolute http(s) URL is ignored with a warning and your configured siteUrl stands, so a mistyped variable can't point a production build at nothing.

The order is: COMPOSE_SITE_URL → the siteUrl above → the built-in default. So locally you normally set nothing and get the configured value. To use a different origin while developing, put it in apps/astro/.env or apps/next/.env (both are gitignored) or prefix the command:

COMPOSE_SITE_URL=http://localhost:4321 bun run build:astro
Bash

A repo-root .env does not reach the app builds, so put it in the app directory. And if you add your own build caching, declare COMPOSE_SITE_URL as part of the cache key: this theme's turbo.json lists it under globalEnv, because a cache that ignores it will happily replay a build made at a different origin.

Brand colour

The palette is CSS custom properties, but the accent (buttons, links, active nav, hover borders) is available directly in config, so you don't have to touch the theme's stylesheets:

export const config = defineThemeConfig({
  brand: {
    theme: "#ff8800",
    themeDark: "#ffb066", // optional; falls back to `theme`
  },
});
TypeScript

Any CSS colour works: hex, rgb(), hsl(), or a keyword.

search.enabled: false removes the search box from the nav entirely. search.maxResults caps the nav dropdown (1–25, default 8); the full /search page always lists every match.

export const config = defineThemeConfig({
  search: { enabled: true, maxResults: 5 },
});
TypeScript

engine and global accept wider values than the theme implements: only engine: "pagefind" and global: true are wired up. Selecting anything else warns at build time and uses the working behaviour rather than silently doing nothing.

A call to action

promo adds one extra button to the home page hero, next to the theme's own. It is for something that is not part of your navigation: a signup, a waitlist, a pricing page, a booking link.

export const config = defineThemeConfig({
  promo: { label: "Start a free trial", url: "https://acme.com/signup" },
});
TypeScript

Nothing renders unless you set it, and both halves are required. A label without a URL, or a URL that is not absolute, warns at build time and the button is dropped rather than published broken.

<Promo /> is available in your content too, so the button can go anywhere, not just the hero.

Two environment variables override it per deployment, which is useful when one repository is published to more than one place and only some of them should carry the button:

COMPOSE_PROMO_URL=https://acme.com/signup COMPOSE_PROMO_LABEL="Start a free trial" bun run build
Bash

Where the blog lives

blogDir names both the content folder and the URL segment, so you can publish at /articles or /posts instead of /blog:

export const config = defineThemeConfig({
  blogDir: "articles",
});
TypeScript

Rename packages/content/blog to match, and update your own menu entry. It must be a single lowercase path segment that the theme doesn't already serve. Anything else, including search or 404, falls back to blog with a warning.

Where the docs live

docSections names the top-level content folders that render as documentation. The default is one, docs; list more to publish independent tracks side by side:

export const config = defineThemeConfig({
  docSections: ["docs", "tutorials"],
});
TypeScript

Each name is both a folder in the content package and the URL segment it mounts at, so packages/content/tutorials is served at /tutorials. See Content organization for what a section contains.

Names that aren't valid path segments are dropped, as is any name that collides with blogDir (one URL segment cannot serve both) or with a page the theme serves itself (search, 404), which would shadow the section's landing page while its child pages kept working. If nothing usable is left, the list falls back to ["docs"]. A name with no folder behind it is skipped with a warning rather than mounting an empty section.

Code blocks

code.maxLines sets where a long block collapses behind an expander, and code.showLineNumbers sets whether line numbers start visible. The toolbar toggle overrides either per block.

export const config = defineThemeConfig({
  code: { maxLines: 18, showLineNumbers: false },
});
TypeScript

Language and dates

lang sets <html lang> and localises dates. The theme ships one maintained set of UI strings (English); strings overrides any of them, so you can translate the chrome without touching theme source. Anything you leave out keeps its English value.

export const config = defineThemeConfig({
  lang: "de",
  dates: { blog: "full", default: "long" },
  strings: {
    to_top: "Zurück zum Anfang",
    in_this_section: "In diesem Abschnitt",
  },
});
TypeScript

Date styles are full, long, medium or short, formatted by Intl in your lang.

There is no copyright line unless you ask for one. The theme won't put a notice on your site that you didn't write. Set copyright to add it, and {year} is replaced with the current year:

export const config = defineThemeConfig({
  copyright: "© {year} Acme Inc. All rights reserved.",
});
TypeScript

Leave it unset and the footer keeps only the back-to-top link.