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" },
],
});
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",
});
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
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`
},
});
Any CSS colour works: hex, rgb(), hsl(), or a keyword.
Search
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 },
});
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" },
});
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
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",
});
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"],
});
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 },
});
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",
},
});
Date styles are full, long, medium or short, formatted by Intl in your lang.
Footer copyright
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.",
});
Leave it unset and the footer keeps only the back-to-top link.