Reference
Blog
The engine handles post collection, pagination, tag extraction, and related-post lookups automatically. You write the page templates; the engine wires up the data.
The engine auto-populates all blog data — you just create the files with the right structure. No configuration needed beyond the file layout.
How it works
pages/blog/— directory for all blog contentpages/blog/index.html— listing page, gets{{ posts }}and{{ pagination }}automaticallypages/blog/my-post.md— individual post, auto-usestemplates/blog/single.html→templates/single.html→templates/layout.html- Posts are sorted by
datedescending. A futuredateschedules the post — it stays hidden until that date
1. Create the blog index — pages/blog/index.html
Preferred approach — declare the collection in frontmatter. The renderer handles iteration and pagination:
---
title: Blog
description: Articles, guides, and updates
collection: blog
item_template: card
perPage: 6
---
<section class="max-w-4xl mx-auto px-6 py-16">
<h1 class="text-4xl font-bold mb-12">Blog</h1>
<div class="grid gap-8 md:grid-cols-2">
{{ collection }}
</div>
{{ paginationHtml }}
</section>
Then create the card template at templates/components/card.html:
If the blog index does not have its own dedicated social-share image, make sure site.defaultImage is set in data/site.yaml so /blog still gets an OG/Twitter image fallback.
<article>
{% if image %}<img src="{{ image }}" alt="{{ title }}" class="w-full aspect-video object-cover rounded-lg mb-4">{% endif %}
<time class="text-sm text-secondary-500">{{ date | date: "%B %d, %Y" }}</time>
<h2 class="text-xl font-semibold mt-1 mb-2">
<a href="{{ url }}" class="hover:text-primary-600">{{ title }}</a>
</h2>
<p class="text-secondary-600">{{ description }}</p>
<a href="{{ url }}" class="text-primary-600 text-sm font-medium mt-3 inline-block">Read more →</a>
</article>
Key points:
collection: blog— collects posts frompages/blog/item_template: card— renders each post throughtemplates/components/card.htmlperPage: 6— enables pagination{{ collection }}— replaced with rendered cards{{ paginationHtml }}— replaced with prev/next navigation
Alternative — use {% for %} directly for full control over card HTML inline:
---
title: Blog
perPage: 6
---
<div class="grid gap-8 md:grid-cols-2">
{% for post in site.blog %}
<article>
{% if post.image %}<img src="{{ post.image }}" alt="{{ post.title }}" class="w-full aspect-video object-cover rounded-lg mb-4">{% endif %}
<h2><a href="{{ post.url }}">{{ post.title }}</a></h2>
<p>{{ post.description }}</p>
</article>
{% else %}
<p class="text-secondary-500">No posts yet.</p>
{% endfor %}
</div>
{{ post.date | date: "%B %d, %Y" }}formats dates
2. Create a single post layout — templates/blog/single.html
This layout is used automatically for all pages/blog/*.md files. It must be a complete HTML document.
<!DOCTYPE html>
<html lang="en">
<head>
{% render 'head', site: site, page: page, canonicalUrl: canonicalUrl, jsonLd: jsonLd %}
</head>
<body class="bg-white text-secondary-900 min-h-screen flex flex-col">
{% render 'header', site: site, currentPath: currentPath %}
<article class="flex-1 max-w-2xl mx-auto w-full px-6 py-16">
<header class="mb-10">
<div class="flex items-center gap-3 text-sm text-secondary-500 mb-4">
<a href="/blog" class="hover:text-primary-600">← Blog</a>
{% if page.date %}<span>·</span><time>{{ page.date | date: "%B %d, %Y" }}</time>{% endif %}
<span>·</span><span>{{ readingTime }} min read</span>
</div>
<h1 class="text-4xl font-bold leading-tight mb-4">{{ page.title }}</h1>
{% if page.description %}<p class="text-xl text-secondary-600">{{ page.description }}</p>{% endif %}
{% if page.image %}<img src="{{ page.image }}" alt="{{ page.title }}" class="w-full rounded-xl mt-8">{% endif %}
</header>
{% if toc.size > 0 %}
<nav class="bg-secondary-50 rounded-lg p-6 mb-10">
<p class="font-semibold mb-3">In this article</p>
<ul class="space-y-1">
{% for item in toc %}
<li class="{% if item.isH3 %}pl-4{% endif %}">
<a href="#{{ item.id }}" class="text-primary-600 hover:underline text-sm">{{ item.text }}</a>
</li>
{% endfor %}
</ul>
</nav>
{% endif %}
<div class="prose prose-lg max-w-none">
{{ content }}
</div>
{% if page.tags %}
<div class="flex flex-wrap gap-2 mt-10 pt-8 border-t border-secondary-200">
{% assign tagList = page.tags | split: ", " %}
{% for tag in tagList %}
<span class="px-3 py-1 bg-secondary-100 text-secondary-700 rounded-full text-sm">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
<div class="mt-10 pt-8 border-t border-secondary-200">
<div class="flex flex-wrap gap-3">
<a href="{{ shareLinks.twitter }}" class="text-sm font-medium text-primary-600 hover:underline">Share on X</a>
<a href="{{ shareLinks.facebook }}" class="text-sm font-medium text-primary-600 hover:underline">Share on Facebook</a>
<a href="{{ shareLinks.linkedin }}" class="text-sm font-medium text-primary-600 hover:underline">Share on LinkedIn</a>
<a href="{{ shareLinks.email }}" class="text-sm font-medium text-primary-600 hover:underline">Share via Email</a>
</div>
</div>
{% if relatedPosts %}
<aside class="mt-12 pt-8 border-t border-secondary-200">
<h2 class="text-xl font-bold mb-6">You might also like</h2>
<div class="grid gap-6 sm:grid-cols-3">
{% for post in relatedPosts %}
<a href="{{ post.url }}" class="group">
{% if post.image %}<img src="{{ post.image }}" alt="{{ post.title }}" class="w-full aspect-video object-cover rounded-lg mb-3">{% endif %}
<h3 class="font-semibold group-hover:text-primary-600">{{ post.title }}</h3>
<p class="text-sm text-secondary-500 mt-1">{{ post.description | truncate: 80 }}</p>
</a>
{% endfor %}
</div>
</aside>
{% endif %}
</article>
{% render 'footer', site: site, year: year %}
</body>
</html>
Important: Reuse the site's existing shell pattern from templates/layout.html. In the first-party starter, that means rendering templates/components/head.html, header.html, and footer.html instead of copying the shell markup into every new layout. If layout.html still keeps a substantial shared shell inline and you're creating the site's first secondary full-document layout (like a blog single template), prefer extracting that shared shell into head.html, header.html, and footer.html first, then render those components from both layouts.
Share links on blog posts
{{ shareLinks }} is auto-injected on markdown posts. Available fields:
{{ shareLinks.twitter }}{{ shareLinks.facebook }}{{ shareLinks.linkedin }}{{ shareLinks.email }}
Use inline links by default. If a site already has a reusable social/share component pattern, you can extract the share markup into a component — but don't assume a share-buttons component already exists.
3. Write the first post — pages/blog/my-first-post.md
---
title: Welcome to Our Blog
description: Introducing our blog — what to expect, who we are, and why we're writing.
date: 2026-01-15
image: https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200&q=80
tags: news, welcome
---
Welcome to the blog! We'll be sharing...
## What to expect
Regular posts about...
## Who we are
...
Frontmatter fields for posts:
| Field | Required | Notes |
|---|---|---|
title |
Yes | Shown in listing and <h1> |
description |
Yes | Shown in listing cards and meta description |
date |
Yes | ISO format YYYY-MM-DD. Used for sorting and display. Future dates schedule the post — it stays hidden until that date |
image |
No | Featured image for the post. Also powers the listing card and OG/Twitter share image. Do not rely on the first inline body image as the social image. |
tags |
No | Comma-separated: tags: design, tutorial. Used for related posts |
publishDate |
No | Optional separate go-live date if display date should differ from publish date |
Tag Archives
Tag archive pages are auto-generated from post tags. No files needed — just use tags in your posts.
/blog/tag/design— all posts tagged "design"/blog/tag/design/page/2— paginated- Works for any collection:
/news/tag/breaking,/products/tag/sale
Template variables on tag archives:
{{ tag }}— the current tag slug{{ posts }}— posts filtered by tag{{ pagination }}— same pagination object as index pages{{ allTags }}— all tags withname,slug,count(also available on the regular blog index for tag clouds)
Tag cloud example (works on both blog index and tag archive pages):
{% if allTags %}
<div class="flex flex-wrap gap-2">
{% for t in allTags %}
<a href="/blog/tag/{{ t.slug }}" class="px-3 py-1 bg-secondary-100 text-secondary-700 rounded-full text-sm hover:bg-primary-100 hover:text-primary-700 transition-colors">
{{ t.name }} ({{ t.count }})
</a>
{% endfor %}
</div>
{% endif %}
Layout cascade: templates/blog/tag.html → templates/tag.html → templates/blog/archive.html → templates/archive.html → templates/layout.html
Rules
- Always create all three — index, single layout, and at least one post — when setting up a blog from scratch
- The single layout must be a complete HTML document — but when
templates/components/head.html,header.html, andfooter.htmlexist, render those components instead of duplicating the shell. If they do not exist andlayout.htmlhas a substantial reusable shell, extract them before building the blog layout. - If you use
{% render %}components in blog layouts, pass all variables explicitly:{% render 'head', site: site, page: page, canonicalUrl: canonicalUrl, jsonLd: jsonLd %},{% render 'header', site: site, currentPath: currentPath %},{% render 'footer', site: site, year: year %} - Use
{{ post.date | date: "%B %d, %Y" }}for formatted dates, never output raw ISO strings {{ toc }},{{ relatedPosts }}, and{{ shareLinks }}are only populated on markdown posts — no need to guard with{% if %}at the top level, but do use{% if toc.size > 0 %}before rendering the TOC nav- Related posts require matching
tags— iftagsis omitted from posts,relatedPostswill be empty - When a post has a featured image, write it to
image:frontmatter. Do not assume the first inline image in the article body will be used for OG/Twitter tags. - Make sure the site has a
site.defaultImageindata/site.yamlwhenever/blog(or any other page) needs a social image fallback and does not set a page-levelimage:. - Comma-separated tags (
tags: ai, web) stay as a string — use{{ page.tags | split: ", " }}to iterate them in templates - To use
{% for post in site.blog %}on pages outside/blog/, addblogtoglobalCollectionsindata/site.yaml. Global access is opt-in — pages that don't need blog posts don't pay the preload cost. Thecollection+item_templatefrontmatter is only needed for the declarative pre-rendered card pattern.