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.
  • category applies only to files whose own frontmatter names no category.
  • A directory that does not exist, is a file, or has an unsupported type fails the build with a message naming the path; an empty directory warns.
  • With at least one extra source, the primary source becomes optional — a site can consist of extra sources alone, and then no metadata.json is 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:

  1. metadata.json is required for a local source. Unknown JSON fields are ignored, which allows direct use of larger export metadata files.

  2. Pages are loaded recursively from pages/.

  3. Posts must be below at least one directory inside posts/. A Markdown file placed directly in posts/ 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 of pages/ load fine).

    Set flat_posts: true to 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 init scaffolds its example post directly in posts/ and turns the key on in the config it writes, so a fresh site builds with the post it was given.

  4. After that first grouping directory, post directories are recursive.

  5. Directories organise files only. Post categories come from frontmatter.

  6. 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 author frontmatter field accepts an ID (1), a name (Ada Lovelace) or a slug (ada); all three resolve through the users block above. An ID with no users entry falls back to author-<id>.
  • Archives list posts only — pages never join an author archive, even with an author field.
  • Templates: author.html renders the archive (context: .Name, .Posts newest-first, .Kind = author); missing author.html falls back to category.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), and author is 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 finalpage_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:

  1. Frontmatter link
  2. Configured permalinks.post or permalinks.page
  3. post_url_format for posts
  4. 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:

  1. Put posts under /blog/ with a permalink pattern:

    1permalinks:
    2  post: /blog/:slug/
    
  2. Add a blog index page that lists the posts. Create pages/blog.md and 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 ssgtheme ships a layouts/blog.html that does this; in a custom theme, a layout that iterates .Site.Posts is all it takes. (hide_from_lists is an ssgtheme convention 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.)

  3. 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.

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.json exists and contains every referenced author/category.
  • Posts are not placed directly in posts/.
  • Frontmatter content has status: publish.
  • type is correct and posts have a valid date.
  • Explicit link and aliases do not collide.
  • ssg ... --check-links=strict succeeds before deployment.