Guide
Content guide
This document is the canonical reference for local Markdown content in SSG. For configuration keys, see CONFIGURATION. For values exposed to themes, see TEMPLATES.
Content source
A normal build selects one source directory:
1ssg my-blog simple example.com
With the default paths, my-blog resolves to content/my-blog/. The source may
also come from source: in a configuration file. MDDB is an alternative remote
source and is documented in CONFIGURATION.
Extra sources (content_sources)
Content that already lives elsewhere — a docs/ folder, notes beside the code,
a package's guides in a monorepo — does not have to be copied into the source
tree. content_sources lists additional flat Markdown roots (loaded
recursively) that are merged into the site:
1content_sources:
2 - path: docs # relative to the working directory, or absolute
3 type: page # page (default) | post
4 category: Documentation # optional; created if metadata.json lacks it
5 - path: ../shared/notes
6 type: post
- Extra sources join the site before finalize, so they get the same URL, permalink, i18n, taxonomy and collision treatment as native content.
categoryapplies only to files whose own frontmatter names no category.- A directory that does not exist, is a file, or has an unsupported
typefails the build with a message naming the path; an empty directory warns. - With at least one extra source, the primary
sourcebecomes optional — a site can consist of extra sources alone, and then nometadata.jsonis required either. - Watch mode watches these directories too.
CLI: --content-source=DIR, repeatable, path-only (type/category need the
config file):
1ssg --content-source=docs ssgtheme example.com --watch
An unset source with no content_sources is a build error that names the
missing settings — and the config loader warns about unknown keys, so a config
written for a newer ssg does not fail silently.
Directory contract
1content/
2└── my-blog/
3 ├── metadata.json
4 ├── pages/
5 │ ├── about.md
6 │ └── legal/
7 │ └── privacy.md
8 ├── posts/
9 │ ├── general/
10 │ │ └── hello.md
11 │ └── 2026/
12 │ └── 07/
13 │ └── release.md
14 └── media/
15 └── hero.jpg
The loader follows these rules:
-
metadata.jsonis required for a local source. Unknown JSON fields are ignored, which allows direct use of larger export metadata files. -
Pages are loaded recursively from
pages/. -
Posts must be below at least one directory inside
posts/. A Markdown file placed directly inposts/is ignored — but the build now names it rather than counting to zero, because a skipped post is otherwise indistinguishable from a frontmatter problem (pages at the top level ofpages/load fine).Set
flat_posts: trueto load them where they are. It is off by default: those files have always been skipped, so a site with a published one it never saw rendered would gain a page nobody asked to publish on its next build.ssg initscaffolds its example post directly inposts/and turns the key on in the config it writes, so a fresh site builds with the post it was given. -
After that first grouping directory, post directories are recursive.
-
Directories organise files only. Post categories come from frontmatter.
-
A supported non-Markdown file beside a page or post is a co-located asset. It is copied when that content references its filename (only files directly beside the Markdown file are supported; nested subdirectories next to content files are skipped).
The pages/ and posts/ names can be changed with pages_path and
posts_path. The source root can be changed with content_dir.
metadata.json
The metadata file supplies categories, authors and exported media records:
1{
2 "categories": [
3 {
4 "id": 1,
5 "count": 3,
6 "description": "General articles",
7 "link": "/category/general/",
8 "name": "General",
9 "slug": "general",
10 "parent": 0
11 }
12 ],
13 "users": [
14 { "id": 1, "name": "Ada Lovelace", "slug": "ada" }
15 ],
16 "media": []
17}
Only the following shapes are consumed:
| Collection | Recognised fields |
|---|---|
categories |
id, count, description, link, name, slug, parent |
users |
id, name, slug |
tags |
id, name, slug — resolves numeric ids in tags: frontmatter and supplies canonical archive slugs (v1.8.6) |
media |
id, slug, title.rendered, media_type, mime_type, source_url, media_details.width, media_details.height, media_details.file |
Additional export fields are allowed but are not exposed as site metadata.
Author archives
Every author referenced by at least one post gets an automatic archive at
/author/<slug>/:
- The
authorfrontmatter field accepts an ID (1), a name (Ada Lovelace) or a slug (ada); all three resolve through theusersblock above. An ID with nousersentry falls back toauthor-<id>. - Archives list posts only — pages never join an author archive, even with
an
authorfield. - Templates:
author.htmlrenders the archive (context:.Name,.Postsnewest-first,.Kind=author); missingauthor.htmlfalls back tocategory.html. Archives appear in the sitemap. - The author archive stays on this fixed pipeline; it is not configurable via
taxonomies:(migrating it onto the generic registry is a documented deferred item), andauthoris a reserved path custom taxonomies cannot claim.
Explicit content wins (v1.8.5). If a page, post or alias already owns an
archive URL — say a hand-written profile with link: /author/ada/ — the
auto-generated archive for that URL is skipped with a build warning instead of
silently overwriting your page, and the suppressed archive stays out of the
sitemap and feeds. The same rule protects /category/…, /tag/… and
/series/… URLs.
Markdown and frontmatter
For predictable output, authored content should use YAML frontmatter:
1---
2title: Understanding WebP
3slug: understanding-webp
4status: publish
5type: post
6date: 2026-07-14
7modified: 2026-07-15
8categories: [Guides]
9author: ada
10tags: [images, performance]
11excerpt: Why WebP can reduce image size.
12featured_image: /media/webp-hero.jpg
13---
14
15The full Markdown article starts here.
Files with frontmatter are included only when status is exactly publish.
Any other value, including an omitted status, is treated as a draft.
A plain .md file without frontmatter is accepted and treated as published.
Frontmatter is still recommended for anything other than imported plain
content, but two values are inferred so such a file is not blank everywhere it
is listed:
| Value | Inferred from | When |
|---|---|---|
title |
the document's first # heading (or a Setext Title / ====) |
always, when frontmatter has no title |
excerpt |
the opening paragraph, capped at 200 characters on a word boundary | only with auto_excerpt: true |
The title fallback is unconditional because an untitled page is broken in every
listing, menu and <title>. The excerpt fallback is opt-in because it changes
card text, feed summaries and meta descriptions on an existing site. Derivation
skips headings, fenced code, tables, block quotes, images, list markers and
Liquid guards ({% … %}), so the excerpt starts at the first real sentence.
Frontmatter fields
| Field | Type | Applies to | Behaviour |
|---|---|---|---|
id |
integer | both | Optional source identifier |
title |
string | both | Display title |
slug |
string | both | URL segment; defaults to the source filename |
status |
string | both | Only publish is rendered when frontmatter exists |
type |
string | both | Use page or post; affects URL and template behaviour |
date |
date | post | Publication date and default date-based URL |
modified |
date | both | Last modification date |
link |
string | both | Explicit URL path; highest URL precedence. A value that already names a file (/validator.html) is final — page_format does not decorate it |
author |
integer/string | post | Author ID, numeric string, name or slug |
categories |
list | post | Category IDs, numeric strings, names or slugs |
category |
string | post | Single free-form category value exposed to templates |
tags |
list | post | Creates tag listings at /tag/<slug>/ |
series |
string | post | Creates a series listing and previous/next navigation |
sticky |
bool | Pin the post to the top of date-ordered listings: the front page, the posts page, .Site.Posts and every term archive. Pinned posts keep their own order among themselves; reaches templates as .Sticky. Feeds are deliberately not pinned — /feed/ reports what was published when, as WordPress does. |
|
excerpt |
string | both | Listing, feed and metadata summary |
description |
string | both | SEO description; themes may fall back to excerpt |
keywords |
string | both | SEO keywords |
canonical |
string | both | Explicit canonical value exposed to templates |
aliases |
list | both | Old paths that 301 here (and, by default, get a stub copy) |
alias_stubs |
bool | both | Override the site-wide default: false = 301 only, no stub copy; true = force a stub |
schema |
map | both | Override/extend this page's generated JSON-LD (deep-merged, per-page wins) |
featured_image |
string | both | Hero image; also og:image, twitter:image and JSON-LD image |
layout |
string | page | Theme layout; redirect also marks an item for sitemap exclusion |
paginate |
int / map | page | Split a collection over this page and /page/N/ siblings, each rendered through the page's own layout with .Pager and .Posts in scope. paginate: 10 pages the site's posts ten at a time; the map form takes over (posts, default, or pages), size (else the site's paginate, else 10) and source (a content root, as in feeds:). Only the first page enters the sitemap. See Paginating a page |
template |
string | page | Specific page template filename override |
robots |
string | both | Robots directive; noindex excludes from sitemap |
sitemap |
string | both | no excludes the item from sitemap.xml |
lang |
string | both | Language used in multilingual output |
Unknown frontmatter fields are retained and flattened into the template's root context. They never replace standard fields with the same name.
Author and category resolution
IDs, names and slugs are supported:
1author: 1
2categories: [1, 5]
1author: Ada Lovelace
2categories: [Guides, Performance]
1author: ada
2categories: [guides, performance]
Name and slug matching is case-insensitive. Numeric strings are converted to
IDs. Values that cannot be resolved through metadata.json are ignored.
Content dimensions
A page is described by more than "post or page". Type, language, taxonomy and
source have been dimensions here for a while — a custom taxonomy is a dimension,
and audience: works today with no new code, see below. Three more arrived in
1.8.60.
Relations
1---
2title: API Authentication
3slug: api-auth-v4
4relations:
5 supersedes: [api-auth-v3]
6 see_also: [oauth-setup, api-keys]
7---
A relation is a link the author names. The build already knew about series, translations and a "related posts" heuristic; it could not know that this page replaces that one, because only the person writing it does.
Values are slugs, resolved in the page's own language, falling back to the default one for a page that has not been translated yet. In a template:
{{ range relationsOf .Page "see_also" }}
<a href="{{ .GetURL }}">{{ .Title }}</a>
{{ end }}
{{/* The inverse — which pages point HERE — is the half nobody can write down,
because the new version does not know what it replaced until the whole
site is loaded. */}}
{{ range relatedBy .Page "supersedes" }}
<a href="{{ .GetURL }}">Superseded: {{ .Title }}</a>
{{ end }}
Relations also become edges in site-graph.json, so
an agent reading the site sees them.
A relation naming a slug the site does not have is a broken link with a name on
it, and follows check_links: silent when link checking is off, a warning when
it is on, and a build failure under strict.
Versions
1---
2title: API Authentication
3slug: api-auth-v4
4version: 4
5version_of: api-auth
6---
version_of groups a chain; version orders it. Numbers sort as numbers, so
10 comes after 2.
The highest version is the latest, and every earlier one gets a canonical pointing at it. That is the part worth having: without it, a document's own old revisions compete with the current one for the same search query, and a site quietly ranks its out-of-date documentation above the page it wants people to read.
Templates get the whole chain:
{{ if not .Page.IsLatest }}
<p class="notice">There is a newer version of this page.</p>
{{ end }}
<ul>
{{ range .Page.Versions }}
<li{{ if .IsLatest }} class="current"{{ end }}><a href="{{ .GetURL }}">Version {{ .Version }}</a></li>
{{ end }}
</ul>
1versions:
2 noindex_old: true # also removes them from the sitemap
noindex_old is off by default: a superseded page is still a page someone may
have linked to. Turning it on removes those pages from the sitemap as well,
through the rule that already drops any noindex page — rather than through a
second switch that could disagree with the first.
URLs are not rewritten. A version lives wherever its file says it does. The
build does not invent /v4/ paths, because a URL that moves on its own is a URL
that breaks.
Outputs per page
1---
2title: API reference
3outputs: [html, json]
4---
Overrides the site's outputs: for this page, in both directions: a reference
page can publish JSON while the rest of the site does not, and a page can opt
out of what the site publishes.
Audience
There is no audience: key, and there does not need to be one — it is a
taxonomy, and custom taxonomies already do this:
1taxonomies:
2 audience:
3 multiple: true
1---
2title: Deploying
3audience: [developer, operator]
4---
That gives archives at /audience/developer/, filtering in templates
(pagesByTerm "audience" "developer") and a term index, all of it working
today. See TAXONOMIES.
Rendering different content for different audiences under one URL is a deliberately separate thing, and is not part of this.
Nothing changes for frontmatter that does not use them
Every one of these keys is optional, and a file without them behaves exactly as
it did — the golden corpora check it. They also stay readable at .Extra.version
and .Extra.relations, so a template that already reads them there keeps
working. That is deliberate: promoting a key to a first-class field would
otherwise take it out of .Extra and break such a template silently.
Excerpts and section markers
SSG supports WordPress-export-style section markers:
1## Excerpt
2A short summary.
3
4## Content
5The full article.
The markers must match exactly and are removed from rendered content. Without
them, everything after frontmatter becomes content. Markers inside fenced code
blocks are treated as code. A top-level # Title line in content is discarded
as an export artifact; use the frontmatter title for the document title.
Slugs and URLs
When slug is absent, it is derived from the filename without .md and
lowercased:
1API.md → api → /api/
Set preserve_slug_case: true to preserve filename or explicit slug casing.
Default URLs are:
| Content | Default URL |
|---|---|
| Page | /<slug>/ |
| Post | /<year>/<month>/<day>/<slug>/ |
URL precedence, from highest to lowest, is:
- Frontmatter
link - Configured
permalinks.postorpermalinks.page post_url_formatfor posts- Default URL
Permalink patterns support :year, :month, :day, :slug and :category:
1permalinks:
2 post: /:year/:month/:slug/
3 page: /:slug/
Expanded paths are sanitised so they cannot escape the output directory.
A clean /blog/ (and why you might see /category/blog/)
SSG does not create a blog section for you, and it never forces posts under
/category/blog/. That URL appears for exactly one reason: a post was given a
category named "blog", and every category auto-generates an archive at
/category/<slug>/. If you want the industry-standard /blog/ and a single
canonical URL per post, don't categorise posts as "blog" — build the section
explicitly instead:
-
Put posts under
/blog/with a permalink pattern:1permalinks: 2 post: /blog/:slug/ -
Add a blog index page that lists the posts. Create
pages/blog.mdand point it at a layout that ranges over the site's posts:1--- 2title: "Blog" 3slug: "blog" 4link: "/blog/" 5layout: "blog" # renders templates/<theme>/layouts/blog.html 6type: page 7hide_from_lists: true 8---The bundled
ssgthemeships alayouts/blog.htmlthat does this; in a custom theme, a layout that iterates.Site.Postsis all it takes. (hide_from_listsis anssgthemeconvention read from a page's custom frontmatter to keep the index itself out of the nav — omit it in a theme that doesn't use it.) -
Leave posts uncategorised. With no
category:/categories:on a post, no/category/blog/archive is generated, so/blog/is the only listing and there is no duplicate-content pair. (Tags and series still work — they live at/tag/<slug>/and/series/<slug>/, separate from the main section.)
This is exactly how this documentation site is built. Reach for a category archive only when you genuinely want a taxonomy facet, not as the primary blog index.
page_format controls filesystem form:
| Value | Result for about |
|---|---|
directory |
about/index.html (default) |
flat |
about.html |
both |
Both forms |
Aliases
Use aliases to preserve old inbound URLs:
1aliases:
2 - /old/permalink/
3 - /2019/legacy-path/
Each alias is always added as a 301 to the _redirects file (chains are
flattened). By default it also gets a noindex HTML redirect stub — a
meta-refresh + canonical fallback for hosts without server redirects. Aliases are
excluded from the sitemap; a collision with real content is skipped with a
warning.
Redirect only, no duplicate copy
On a platform that serves _redirects (Cloudflare Pages, Netlify) the stub copy
is redundant: the old path already 301s at the edge. To emit only the 301 —
no 200-serving duplicate for crawlers to spend budget on — turn stubs off. Set it
site-wide in the config (alias_stubs: false) or per page in frontmatter:
1aliases:
2 - /hotel-term-condition/
3alias_stubs: false # this page's aliases 301 only; no stub copy
Per-page alias_stubs overrides the site-wide default either way — set it true
on a page to keep its stub even when the site disables stubs. The 301 is
emitted regardless; only the stub copy is affected.
Trailing slash. The 301 is written for the alias path exactly as declared,
while the stub copy is a directory index (/old-slug/index.html). On a host that
serves _redirects, a hand-written rule for /old-slug and the stub at
/old-slug/ can therefore answer differently per trailing slash (one 301s, the
other serves the 200 copy). If that split matters, set alias_stubs: false so
the alias is a pure 301 and there is no copy to disagree with the redirect.
Relative Markdown links
Links to source Markdown files are rewritten to their generated URLs. This is
on by default (since 1.8.15) — a raw .md link otherwise 404s or serves the
source file; set rewrite_md_links: false to opt out. The behaviour is
unchanged, only the default:
1See [Authentication](AUTHENTICATION.md) and [API](../reference/API.md).
SSG resolves the source filename and final slug. Unknown .md links remain
unchanged. The feature is disabled by default for sites that intentionally
publish raw Markdown.
Assets and static files
- Referenced images, media, archives and documents beside a Markdown file are copied as co-located assets (only files directly beside the Markdown source are supported; nested subdirectories next to content files are skipped). Unreferenced siblings are skipped.
content/<source>/media/contains source media records/files.- Project-level
static/is copied recursively and verbatim to the output. - Theme CSS, JavaScript and images are copied from the selected template.
Use static/ for favicons, downloads, manifests and files SSG should not parse.
See IMAGES for generated image variants and template image
helpers.
Data-driven content features
Tags and series
tags generates /tag/<slug>/ listings. series generates
/series/<slug>/ and exposes previous/next series values to templates. A theme
may provide series.html; otherwise the category template is used.
Multilingual content
When languages is configured, the default language stays at the site root and
other languages are emitted under /<lang>/. Set lang on each item to select
its language. Templates receive language, translation and hreflang context;
see TEMPLATES.
Dates
timezone and language_timezones affect content dates used by templates and
date permalink tokens. Feeds and sitemap timestamps remain UTC. With
lastmod_from_git, sitemap modification dates come from the source file's last
Git commit and fall back to frontmatter/file dates when unavailable — for a
verbatim static_sources document, which has no frontmatter, the fallback is the
file's modification time.
git has to be on PATH. When it is not, every date quietly comes from the
fallback instead, which looks like the setting working until the dates are
compared. The build now says so once. The case that hits it in practice is the
snap: strict confinement shows a snap only its own rootfs, and the snap
bundles cwebp and avifenc for exactly that reason but not git, so
lastmod_from_git cannot reach a repository there. Use the DEB, the tarball or
the Docker image on a site that depends on it.
Paginating a page
paginate in the site config pages every generated listing — the post index,
category, tag, author, date and type archives. A page you wrote could not: it
had no .Pager, and a template can slice .Site.Posts but cannot write a
second file, so /blog/page/2/ was a 404 whatever it linked to.
The case that needs it is a page that renders a listing and something else
— an aggregated feed above the site's own posts, an introduction, a call to
action — which is exactly why it is a page and not the generated listing.
Setting posts_page would hand the URL to the generator and lose the rest.
A page opts in from its own frontmatter:
1---
2title: Blog
3slug: blog
4layout: blog
5paginate: 10 # the site's posts, ten per page
6---
or, naming what it pages over:
1paginate:
2 over: posts # posts (default) or pages
3 size: 10 # else the site's `paginate`, else 10
4 source: blog # optional: one content root, matched as feeds: does
The build writes /blog/, /blog/page/2/, … through the page's own layout.
Every page of it gets .Posts (that page's slice) and .Pager (Current,
Total, PrevURL, NextURL, Pages), the same shape an archive gets, so one
pager partial serves both. Page 2 canonicalises to itself, in .CanonicalURL
and in the og:url and JSON-LD the SEO pass injects.
Two rules carry over from the generated listings: only the first page enters
sitemap.xml (the tail is a slice of one document, not more documents), and the
page's .md/.json outputs and aliases belong to page 1 alone. A page whose
link: names a file (/blog.html) has nowhere to put a /page/2/ and is
rendered whole, with a warning that says so.
WordPress-compatible media shortcodes
When migrating content from a WordPress site, the content might contain legacy media shortcodes. SSG has native, built-in support for parsing and sanitizing the following bracket shortcodes:
[youtube]VIDEO_ID[/youtube]— Renders the standard YouTube video embed responsive iframe.[embed]VIDEO_URL[/embed]— Renders the responsive video player iframe.
These shortcodes are automatically protected from the HTML sanitizer (sanitize_html: true) via a secure token bypass mechanism, preventing the iframe elements from being stripped out during the build phase while still neutralising other malicious HTML.
To strip these shortcodes from your text in lists or feeds, use the stripShortcodes template helper. To extract the YouTube thumbnail image from a post's content, use the thumbnailFromYoutube helper.
Publication checklist
metadata.jsonexists and contains every referenced author/category.- Posts are not placed directly in
posts/. - Frontmatter content has
status: publish. typeis correct and posts have a validdate.- Explicit
linkand aliases do not collide. ssg ... --check-links=strictsucceeds before deployment.