Skip to main content
SiteShi p

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 content
  • pages/blog/index.html — listing page, gets {{ posts }} and {{ pagination }} automatically
  • pages/blog/my-post.md — individual post, auto-uses templates/blog/single.htmltemplates/single.htmltemplates/layout.html
  • Posts are sorted by date descending. A future date schedules 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 from pages/blog/
  • item_template: card — renders each post through templates/components/card.html
  • perPage: 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.

{{ 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 with name, 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.htmltemplates/tag.htmltemplates/blog/archive.htmltemplates/archive.htmltemplates/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, and footer.html exist, render those components instead of duplicating the shell. If they do not exist and layout.html has 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 — if tags is omitted from posts, relatedPosts will 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.defaultImage in data/site.yaml whenever /blog (or any other page) needs a social image fallback and does not set a page-level image:.
  • 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/, add blog to globalCollections in data/site.yaml. Global access is opt-in — pages that don't need blog posts don't pay the preload cost. The collection + item_template frontmatter is only needed for the declarative pre-rendered card pattern.

Found something out of date? Open an issue. ← All docs