A starter Masthead theme. Clone it, customize the templates and stylesheet, zip the result, and upload it through the Themes page on any Masthead instance.
This template is an exact copy of Masthead's built-in default theme —
minimal, readable, opinionated about typography. It's the simplest of the
shipped looks and the easiest to fork. The only difference from the
built-in is the slug (the built-in's default slug is reserved, so this
template ships as starter — rename it to whatever you like).
manifest.json required — theme metadata + customizable tokens
theme.css required — the stylesheet inlined into <style>
templates/
layout.liquid required — wraps every render target
index.liquid required — site homepage (post list)
post.liquid required — single blog post
page.liquid required — standalone page (markdown / html body)
not_found.liquid required — site-scoped 404
pages/ optional — theme pages (see below)
blog.liquid a theme page template
blog.json its settings config (sidecar)
assets/ optional — images, fonts, additional CSS/JS
Every file listed as required must be present in the zip Masthead
extracts, or the upload will fail validation. blog is no longer a
required fixed template — the post-list page now ships as a theme page
under templates/pages/, and you're free to delete it or add your own.
manifest.json declares the theme's identity and two kinds of site-wide
customization surface: tokens (knobs that apply to the whole site) and
metadata (default per-page settings for ordinary markdown/html pages).
{
"name": "Default",
"slug": "my-theme",
"version": "1.0.0",
"author": "Masthead",
"description": "Minimal, readable, opinionated about typography.",
"tokens": [
{ "key": "accent", "label": "Accent color", "type": "color",
"default": "#0066cc", "category": "Appearance" },
{ "key": "favicon", "label": "Favicon", "type": "file",
"default": "", "category": "Appearance" },
{ "key": "show_search", "label": "Show post search", "type": "boolean",
"default": false, "category": "Posts" }
],
"metadata": [
{ "key": "layout", "label": "Page layout", "type": "select",
"options": ["contained", "wide"], "default": "contained" },
{ "key": "show_navigation", "label": "Show site navigation",
"type": "boolean", "default": true }
]
}slugis the unique identifier. It must be 1–32 chars; lowercase letters, digits, and hyphens; and must start and end with a letter or digit. The slugsdefault,studio, andtailwindare reserved for built-ins — pick something else.versionuses semver. Re-uploading a theme updates it in place only when the manifest version is a strictly-newer SemVer than the installed copy; otherwise the upload is rejected. Bump it whenever you ship a change you want existing sites to pick up.
tokens declares knobs that show up on each site's Settings screen.
Most tokens become CSS custom properties; the file type resolves to an
uploaded asset. Tokens are always scalar — they can't hold nested
objects or lists.
To expose a new customizable value, add a token here and use it in
theme.css as var(--my-key). Masthead emits the effective tokens as CSS
custom properties at render time, after theme.css, so any
var(--accent) reference picks up the overridden value:
:root { --accent: <user value or default>; }file tokens in CSS. A file token is emitted as a CSS url(...),
so reference it with var() where a URL is expected
(background-image: var(--hero)). An empty file token (no upload
chosen) resolves to an empty string, so guard with a sensible default or
let the rule no-op.
Categories. tokens[].category (optional) groups the token under a
labelled accordion on the Settings screen. Tokens with no category fall
under "General". Use categories ("Appearance", "Typography",
"Posts", …) to keep a large token set navigable.
See Field types below for the full list of scalar types.
metadata declares per-page settings for markdown / html pages — the
fields show up on the page editor's "Page settings" section. The
active theme drives the form: every field you declare becomes an input the
user fills in per page, and the values reach your templates as
page.metadata.<key>.
Unlike tokens, top-level metadata fields may be containers (object
and list), so a single page can carry structured settings. See Field
types below.
<article{% if page.metadata.layout == "wide" %} class="wide"{% endif %}>
{{ body_html }}
</article>Theme-switch resilience. Unknown metadata keys are preserved on save — a page authored under theme A and edited under theme B keeps A's keys even though the form doesn't show them, so switching back to A restores everything. Tokens behave differently: unknown overrides are dropped, because tokens are inert without a matching CSS variable.
Type coercion. Form posts always arrive as strings; the renderer
coerces declared fields to their type before the template sees them. You
can branch directly on {% if page.metadata.show_navigation %} without
the "true" / "false" footgun.
Empty values fall back to defaults. Blanking an input on save strips that key, so the next render reads the manifest default again.
A theme page is a full-page template you ship with the theme, used for
pages whose content is structured settings rather than a freeform
markdown/html body. The post list (blog), a landing page, a contact
page, a pricing table — anything where the author fills in fields instead
of writing prose.
A theme page is two files in templates/pages/:
templates/pages/landing.liquid the Liquid template
templates/pages/landing.json its settings config (the "sidecar")
The basename ties them together (landing.liquid ↔ landing.json). The
.liquid file renders the page; the .json file declares the editable
settings. A .liquid with no matching .json is a theme page with no
settings.
{
"label": "Landing",
"description": "A marketing landing page with a hero and feature grid.",
"metadata": [
{ "key": "headline", "label": "Headline", "type": "string",
"default": "Welcome" }
]
}label— the human name shown in the page editor's template dropdown (falls back to the filename).description— optional helper text shown under the dropdown.metadata— the field schema for this page, identical in shape to the manifest'smetadata(see Field types). Values reach the template aspage.metadata.<key>.
There is no version in a sidecar — page configs travel with the
theme and are versioned by the manifest.
In the page editor, the author picks the Theme page format, then
chooses one of your theme pages from a dropdown. The page's "settings"
form is built from that page's sidecar metadata; there is no body
editor. The template can't be changed once the page has been saved —
pick the right one up front. (Switching a new, unsaved page between
templates is fine.)
blog ships as a theme page in this template: set a page's format to
Theme page → Blog, and optionally make it the site homepage, to get
the post list as your front page.
Tokens, manifest metadata, and theme-page sidecar metadata all share
one field model. Scalar types are allowed everywhere; container
types (object, list) are allowed only at the top level of a
metadata schema (manifest or sidecar), never in tokens and never nested
inside another container (one level of nesting only).
| Type | Editor | Template sees |
|---|---|---|
string |
single-line text | the string |
text |
multi-line textarea | the string |
color |
color picker | #rrggbb |
length |
text input | CSS length (880px, 60ch) |
number |
numeric input | a number (123, 1.5) |
url |
text input (URL-validated) | the string |
boolean |
checkbox | true / false |
select |
dropdown over options |
the chosen option string |
file |
picker over the site's uploads | a public URL (resolved from the stored upload id at render time) |
defaultis required for every scalar field and is used when there is no override.optionsis required forselect— a non-empty array of strings.description(optional) renders as helper text under the input.category(optional) groups the field under a labelled accordion in the editor, exactly like token categories.
A group of scalar fields with a single value (a map). Use it to bundle related settings, e.g. a hero block.
{ "key": "hero", "label": "Hero", "type": "object",
"fields": [
{ "key": "title", "label": "Title", "type": "string", "default": "Welcome" },
{ "key": "image", "label": "Background", "type": "file", "default": "" }
]
}<section style="background-image: url({{ page.metadata.hero.image }})">
<h1>{{ page.metadata.hero.title | escape }}</h1>
</section>fields(required, non-empty) holds the nested scalar fields.- An object's default is derived from its fields' defaults — you don't
give the object itself a
default.
A repeatable group whose value is an array of maps. The author can add, remove, and drag-reorder items in the editor.
{ "key": "features", "label": "Features", "type": "list",
"item_label": "Feature",
"fields": [
{ "key": "title", "label": "Title", "type": "string", "default": "" },
{ "key": "icon", "label": "Icon", "type": "file", "default": "" }
],
"default": [
{ "title": "Fast" },
{ "title": "Simple" }
]
}<ul class="features">
{% for f in page.metadata.features %}
<li>
{% if f.icon != "" %}<img src="{{ f.icon }}" alt="">{% endif %}
{{ f.title | escape }}
</li>
{% endfor %}
</ul>fields(required, non-empty) holds the nested scalar fields each item carries.item_label(optional) names a single row in the editor (the "Add <item_label>" button, row headings).default(optional) is an array of seed items, rendered and editable out of the box; omit it (or use[]) to start empty.filefields inside list items resolve to public URLs just like top-level file fields.
Templates are Liquid — the safe, sandboxed templating language used by Jekyll and Shopify. They have no access to Elixir, the filesystem, or the database — the rendering context is a fixed set of plain maps.
- The inner template (e.g.
post.liquid, or apages/<name>.liquid) renders against the page's context. - The result is passed to
layout.liquidas the variablecontent. - The final HTML is sent to the browser.
| Variable | layout | index | post | page | theme page | not_found |
|---|---|---|---|---|---|---|
site |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
pages (nav list) |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
theme.tokens.<key> |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
theme.css / content |
✓ | |||||
posts |
✓ | ✓ | ||||
post |
✓ | |||||
page |
✓ | ✓ | ||||
page.metadata.<key> |
✓ | ✓ | ||||
page.template |
✓ | |||||
body_html |
✓ | ✓ | ||||
tags / current_tag |
✓ (blog) |
Each shape:
site { name, title, description, slug, css_overrides, homepage_slug }
post { title, slug, excerpt, published_at, url }
page { title, slug, format, template, url, metadata }
site.homepage_slugis the slug of the page chosen as the homepage (empty when the homepage is the default post list) — handy for marking the active nav item.page.templateis the theme-page name (e.g."blog") for theme pages, empty otherwise.page.metadatais the merge of the schema defaults (manifestmetadatafor ordinary pages, the sidecarmetadatafor theme pages) and whatever the author filled in.- Theme pages receive the full
postslist, so ablogpage can render it. On a blog theme page,tagslists the site's tags andcurrent_tagreflects the?tag=filter, so you can build a tag filter (eachtaghasname,slug,active). body_htmlarrives pre-sanitized by Masthead's HTML allowlist — the one variable that intentionally emits raw HTML. Theme pages have no body editor, so they don't getbody_html(theblogpage guards for it).
Liquid does not auto-escape output. Any string a site owner can edit
(site name, page title, post title, a metadata string/text field, …)
must be piped through escape:
<h1>{{ site.title | default: site.name | escape }}</h1>This is the single most important footgun to remember when authoring a
theme. If you forget, a value like <script>alert(1)</script> will
execute in every visitor's browser. (file fields are URLs, not free
text, so they don't need escaping — but never interpolate one into a
javascript:-capable context.)
Masthead ships the standard Liquid filter set (escape, default, date,
size, …) plus three additions:
| Filter | Use | Example |
|---|---|---|
strftime |
Calendar-style date formatting on DateTime values |
`{{ post.published_at |
iso8601 |
Safe-on-nil ISO-8601 for <time datetime="..."> |
`<time datetime="{{ post.published_at |
asset_url |
Build a URL to a theme-bundled asset | `<img src="{{ 'logo.png' |
The masthead CLI renders
your theme through the exact same pipeline as the live platform — no
database, no upload step:
masthead preview # serves http://localhost:4010, re-reads on refresh
masthead validate # parse manifest + templates + page configs
masthead package # build an installable zippreview ships realistic sample content and a live token inspector, so
you can iterate on templates, theme.css, and page settings and see the
result on refresh.
The zip Masthead consumes is exactly:
manifest.json
theme.css
templates/layout.liquid index.liquid post.liquid page.liquid not_found.liquid
templates/pages/*.liquid + *.json (theme pages + their sidecar configs)
assets/… (optional)
Build it with the CLI (masthead package) or by hand:
zip -r my-theme.zip manifest.json theme.css templates assetsThen in the Masthead admin: Themes → + Upload theme, drop the zip in the modal, click Install. The validator checks size caps, path safety, the manifest schema, every Liquid template, and every page sidecar config before promoting the files into object storage. Once installed, the theme shows up in the Theme dropdown on each site's settings page.
- Set a non-reserved
sluginmanifest.json. - Bump
versionwhenever you ship a meaningful change. - Edit
theme.css— the cascade istheme.cssfirst, then your token overrides, then the site's free-text CSS overrides. - Edit the templates. Always
| escapeuser-supplied strings. - Add or remove tokens / metadata fields, grouping them with
category. The settings UI and page editor rebuild their forms from the manifest and sidecars, so every declared field automatically gets an input. - Add theme pages under
templates/pages/(a.liquid+ a.json) for structured, settings-driven pages like landings or the post list.
- Archive: max 5 MB compressed, 25 MB uncompressed, 200 files.
- Asset extensions allowed:
.css .png .jpg .jpeg .gif .webp .svg .woff .woff2 .ttf .otf .ico .json. - Per-site CSS overrides: max 50 KB.
- No
../ absolute paths / Windows-style separators in zip entries. - Container fields nest one level (objects/lists hold scalars only).
- Partials (
{% include %},{% render %}) are not supported in v1.
MIT — do whatever you want with this template.