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.
one-of<a|b> One of the strings it names, and nothing else.
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`

Values, not just shapes

A type says what a field is. Three keys beside it say what it may hold.

schema {
  status "one-of<draft|review|published>" default="draft"
  weight "int" min=1 max=10
  title "str" min=2 max=80
  tags "list" max=5 pattern="^[a-z][a-z0-9-]*$"
}
Key Holds On
min A floor: a number’s own value, a string’s length, a list’s size. int, float, str, list<T>
max A ceiling, read the same way. the same
default What the page gets when it writes none, which also lets the field be absent. any scalar type
pattern A regular expression every string the value carries must match. str, list<str>

A pattern holds the string itself, or every element of a list of them, so the fault names the element that broke (tags.2). It is unanchored, as a regular expression is: write ^..$ to hold the whole of the value rather than a part of it. A pattern that does not compile is refused where it is written.

one-of is part of the type language rather than a key, so it composes like every other type: list<one-of<draft|published>> is a list of them, and a dict field can hold one.

A value of the right type that a field refuses anyway says so as a value:

  × frontmatter `status` must be one of "draft", "review", "published"
   ╭─[content/blog/post.typ:3:11]
 3 │   status: "reviewed",
   ·           ─────┬────
   ·                ╰── not what the schema allows
   ╰────

A default is what the page would have written, and lands where that would have: page.frontmatter.status reads "draft" on a page that declared none, and every listing, feed and card sees it too. What the page wrote always wins.

NOTE

A constraint the type cannot answer for is refused where it is written, not left to never fire: min on a bool, a floor above its own ceiling, or a default the declared type would reject are all config errors.

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. A blog schema says nothing about notes, and a collection with no schema block constrains nothing.