Frontmatter
A page’s metadata is a Typst binding, not a header in another language:
#let frontmatter = (
title: "Hello",
date: datetime(year: 2026, month: 7, day: 9),
tags: ("intro",),
)
The build reads that one top-level frontmatter export from the evaluated module. Because Typst evaluates it, the value is an ordinary dict: it can be computed from sys.inputs, from a loaded data file, or from anything else in scope.
A markdown page declares the same keys in a fenced block instead, in YAML, TOML or KDL. Every key on this page behaves identically there: the block is read into the same dict, and everything below is the same walk.
WARN
Single-element arrays need the trailing comma.("intro") is a string; ("intro",) is a list.
Recognized keys
| Key | Type | Does |
|---|---|---|
title |
str | The page title, used in <title>, listings, feeds and social tags. |
date |
datetime | str | When it was published. A page joins the feeds and sorts in a sort "date" collection only when it has one. |
updated |
datetime | str | When it last changed materially. Reported as the sitemap’s lastmod and a feed entry’s updated, while date stays put. |
expiry |
datetime | str | The last day it is published. After that it is left out of the build entirely, and prune removes what an earlier build wrote. |
draft |
bool | Skip the page unless --drafts is passed. |
slug |
str | Override the URL slug, otherwise derived from the filename. |
path |
str | Publish at exactly this URL, replacing the collection’s permalink and the slug both. |
lang |
str | Override the page’s language, beating the filename suffix and the site default. |
translation |
str | The key pairing this page with its editions in other languages. |
template |
str | The template that wraps this page, overriding the collection’s default. |
order |
int | The sort key for a sort "order" collection. |
redirect |
list | One old path, or a list of them, forwarded to this page. |
source |
str | The name of a paths { sources } entry whose file is this page’s body. Markdown pages only. |
description |
str | The one-line summary in <meta>, the feed entry, the listing row and the announced record. |
summary |
str | An alias for description, used when there is no description. |
image |
str | The social preview image, which wins over a generated card. |
alt |
str | What that image shows. Empty marks it decorative. |
author |
str | Who wrote the page, over the site default for its language. |
exclude |
list | The generated files this page declines to appear in. See Opting out. |
Values are typed. A key with the wrong type stops the build with a diagnostic naming the file and the field, never a silent drop. The three dates take either a Typst datetime or a string spelled exactly YYYY-MM-DD, so a day needs no datetime(..) call:
date: "2026-08-05",
lang and translation only matter once a languages block exists; see multiple languages. redirect is covered on redirects.
description, summary, image, alt and author drive meta and social tags, the feeds, and what a listing row carries:
#let frontmatter = (
title: "A post",
description: "A short summary for search and social cards.",
image: "cover.png",
alt: "The cover, described for a reader who cannot see it",
)
They read the same in a listing, as entry.description, entry.image, entry.alt and entry.author, so a card component draws the very image the og:image tag names.
Naming a URL outright
path publishes a page where you say, whatever its collection’s permalink pattern would have produced:
#let frontmatter = (
title: "About",
path: "/about-us/",
)
Leading and trailing slashes are optional, so about-us is the same URL. A path whose last segment carries an extension names a file and publishes as one, which is what a site arriving from Jekyll needs:
path |
Writes |
|---|---|
/about-us/ |
about-us/index.html, served at /about-us/ |
/2019/post.html |
2019/post.html, served at that URL |
It is the migration escape hatch: copy each old URL onto the page that answers it and the URL set matches by construction, with no permalink archaeology. Two pages claiming one path is an error naming both files, not a race.
path does not touch the page’s identity. Translations still pair on collection/slug, so each edition may state its own path and stay the same page in another language; an edition that states none publishes under its language prefix as usual.
Taxonomy keys
Any key declared under content { taxonomies } is recognized too, and collects a list of terms:
#let frontmatter = (
title: "On Typst",
tags: ("typst", "tooling"),
)
With taxonomies { tags } configured, this page is grouped under both terms. See taxonomies.
Opting out
The site decides whether a sitemap, a feed, a search index, a social card or a PDF is generated at all. A page decides whether it is in them:
#let frontmatter = (
title: "Thanks for subscribing",
exclude: ("sitemap", "search"),
)
| Name | Leaves out |
|---|---|
sitemap |
The page’s entry in sitemap.xml. |
feed |
Its entry in every syndication feed. |
search |
Its entry in the client-side search index. |
card |
Its social card, and the og:image that names one. |
pdf |
Its PDF, and the <link rel="alternate"> that points at one. |
A name outside that list fails the build rather than being ignored.
NOTE
exclude names generated files, not listings. A page left out of the search index is still listed by its collection index, and still reachable: to leave a page out of listings, see content { drafts } and expiry above.
Everything else
Unknown keys aren’t errors. They pass through as extra frontmatter, yours to read in a template as page.frontmatter.<key> and in a listing as entry.extra.<key>.
NOTE
A key that is a near-miss of a recognized one (titel, tag, descripton) is treated as a typo and reported with a suggestion, not passed through.
Schemas
Free-form keys are easy to get wrong: a template reading a hero that a page never set renders an empty hole and the build stays green. A collection can say what its members must carry:
content {
collections {
blog {
schema {
title "str"
tags "list"
hero "str" optional=#true
author
}
}
}
}
Declaring a field requires it. optional=#true lets it be absent. A field with no type (author above) is required but unconstrained. A page that breaks the schema fails the build, pointing at the line in its frontmatter:
× frontmatter `hero` must be a string, but is of type `integer`
╭─[content/blog/post.typ:3:9]
2 │ title: "Hello",
3 │ hero: 3,
· ┬
· ╰── not the declared type
╰────
help: write it as `hero: ".."`, or change the `blog` collection's schema
The types are the Typst ones a frontmatter dict can hold, not a second type system:
| Type | Holds |
|---|---|
str, bool, int, float |
The scalar of that name. |
date |
A datetime(..), with or without a time of day. |
list<T> |
An array whose every element is a T. Bare list is list<str>. |
dict |
A dictionary, whose own fields a block declares. |
any |
Anything: the field must merely be there. |
A list says what it holds, so the parameter nests as deep as the data does:
schema {
widths "list<int>"
matrix "list<list<int>>"
editor "dict" {
name "str"
email "str" optional=#true
}
reviewers "list<dict>" {
name "str"
}
}
A block declares the fields of the dictionary the type ends in, through however many lists wrap it. Nested fields are checked the same way, and are required unless they say optional=#true themselves. The diagnostic names the one that broke, down to the element:
× frontmatter `reviewers.1.name` must be a string, but is of type `integer`
A recognized key can appear in a schema, to require it. Its type is already fixed by the build, so declaring a different one (title "int") is a config error rather than a rule no page could satisfy.
NOTE
Schemas are per collection. Ablog schema says nothing about notes, and a collection with no schema block constrains nothing.