Skip to content

Grav Twig Templating — Build Dynamic Page Templates

DodaTech Updated 2026-06-27 5 min read

In this tutorial, you'll learn Twig templating — the engine that powers all Grav themes.

What You'll Learn

  • How Twig templates work in Grav
  • Template inheritance with blocks
  • Page variables: title, content, url, children, etc.
  • Twig filters, loops, and conditionals
  • Creating a custom page template

Why It Matters

WordPress uses PHP mixed with HTML in templates. Grav uses Twig — a clean, secure, sandboxed template engine. Twig separates logic from presentation, prevents XSS by auto-escaping output, and makes templates readable and maintainable.

Real-World Use

In DodaTech's documentation sites, each content type gets its own Twig template: a default.html.twig for standard pages, a docs.html.twig with sidebar for technical documentation, and a landing.html.twig for marketing pages. Template inheritance keeps the base layout (header, footer, nav) in one file.

How Grav Finds Templates

When Grav renders a page, it looks for a template based on the page's Markdown filename:

Filename Template Looked Up
default.md default.html.twig
blog.md blog.html.twig
item.md item.html.twig
landing-page.md landing-page.html.twig

Templates live in user/themes/YOURTHEME/templates/.

Template Inheritance

Twig's most powerful feature: block-based inheritance.

Base Layout

partials/base.html.twig:

<!DOCTYPE html>
<html>
<head>
    <title>{% block title %}{{ site.title }}{% endblock %}</title>
    <link rel="stylesheet" href="{{ url('theme://css/styles.css') }}">
</head>
<body>
    {% block header %}
        {% include 'partials/header.html.twig' %}
    {% endblock %}

    <main>
        {% block content %}{% endblock %}
    </main>

    {% block footer %}
        {% include 'partials/footer.html.twig' %}
    {% endblock %}

    <script src="{{ url('theme://js/main.js') }}"></script>
</body>
</html>

Child Template

default.html.twig:

{% extends 'partials/base.html.twig' %}

{% block title %}
    {{ page.title }} | {{ site.title }}
{% endblock %}

{% block content %}
    <article>
        <h1>{{ page.title }}</h1>
        <div class="page-content">
            {{ page.content|raw }}
        </div>
    </article>
{% endblock %}

The child template only overrides the blocks it needs. Everything else comes from the base layout.

Essential Page Variables

These variables are available in every template:

Variable Description Example Value
page.title Page title from frontmatter "About"
page.content Rendered HTML content <h1>About</h1><p>...</p>
page.url Page URL "/about"
page.header All frontmatter as object page.header.menu
page.parent Parent page object page.parent.title
page.children Child pages collection Loop with {% for %}
page.media Media files in the page folder Images, PDFs
page.summary First part of content "This is a..."
page.modular Whether page is modular true or false
page.active Whether this is the current page true or false
site.title Site title from site.yaml "My Site"
site.metadata Site metadata site.metadata.description
config Full configuration object config.themes.quark
uri URI helper uri.path, uri.query
theme Theme variables theme.color_scheme

Using Page Variables in Templates

<article>
    <h1>{{ page.title }}</h1>
    <p class="meta">
        Last updated: {{ page.header.lastmod|default(page.date)|date('M d, Y') }}
    </p>
    <div class="content">
        {{ page.content|raw }}
    </div>
</article>

{% if page.children.count > 0 %}
    <nav class="subpages">
        <h2>Sub-pages</h2>
        <ul>
        {% for child in page.children %}
            <li><a href="{{ child.url }}">{{ child.title }}</a></li>
        {% endfor %}
        </ul>
    </nav>
{% endif %}

Twig Filters

Filters transform variables. Common ones in Grav:

{{ 'hello'|upper }}               {# HELLO #}
{{ 'HELLO'|lower }}               {# hello #}
{{ 'hello world'|title }}          {# Hello World #}
{{ 'hello'|capitalize }}           {# Hello #}
{{ '<p>text</p>'|striptags }}      {# text #}
{{ 'text'|default('fallback') }}   {# text #}
{{ ''|default('fallback') }}       {# fallback #}
{{ page.content|raw }}            {# Raw HTML, no escaping #}
{{ 'now'|date('M d, Y') }}        {# Jun 27, 2026 #}
{{ page.title|slice(0, 10) }}     {# First 10 chars #}

Important: Use |raw when rendering HTML content to avoid double-escaping:

{# Correct: renders HTML #}
{{ page.content|raw }}

{# Wrong: outputs escaped HTML tags #}
{{ page.content }}

Loops and Conditionals

Looping Over Children

{% for child in page.children %}
    <div class="card">
        <h3><a href="{{ child.url }}">{{ child.title }}</a></h3>
        <p>{{ child.summary(200)|striptags }}</p>
    </div>
{% else %}
    <p>No sub-pages yet.</p>
{% endfor %}

Conditional Content

{% if page.header.published %}
    <article>
        {{ page.content|raw }}
    </article>
{% else %}
    <p>This page is not yet published.</p>
{% endif %}

Looping with Index

{% for child in page.children %}
    <div class="item item-{{ loop.index }}">
        {{ child.title }}
    </div>
{% endfor %}

Creating a Custom Template

Let's create a "card listing" template that shows child pages as cards.

Step 1: Create the Template

user/themes/quark/templates/cards.html.twig:

{% extends 'default.html.twig' %}

{% block content %}
    <div class="card-grid">
        {% for child in page.children %}
            <div class="card">
                {{ child.media.images|first.html|default('') }}
                <h2><a href="{{ child.url }}">{{ child.title }}</a></h2>
                <p>{{ child.summary(150)|striptags }}</p>
                <a href="{{ child.url }}" class="btn">Read More</a>
            </div>
        {% endfor %}
    </div>
{% endblock %}

Step 2: Use It

Create a page that uses this template:

Create user/pages/05.docs/cards.md:

---
title: Documentation
template: cards
---

When you visit /docs, Grav renders the page using cards.html.twig instead of default.html.twig.

Common Twig Mistakes

| Mistake | Symptom | Fix | |---------|---------|-----| | Missing |raw | HTML tags visible in output | Use {{ page.content|raw }} | | Undefined variable | Blank output or error | Check the variable name matches what Grav provides | | Wrong block name | Content doesn't appear | Blocks must match between base and child templates | | Forgot {% extends %} | Base layout missing | Add {% extends 'partials/base.html.twig' %} | | Infinite loop | Page crash | Ensure recursive macros have a base case |

Learning Path

flowchart LR
  A["What is Grav?"] --> B["Installation"]
  B --> C["Pages & Content"]
  C --> D["Navigation"]
  D --> E["Twig Templating
← You are here"]:::current E --> F["Themes"] F --> G["Taxonomy & Blog"] G --> H["Plugins & Admin"] H --> I["Configuration & Caching"] I --> J["Deployment & Maintenance"] classDef current fill:#38bdf8,color:#0f172a,stroke-width:2px

Practice Questions

  1. What Twig filter prevents HTML escaping when rendering page content? Answer: |raw — e.g., {{ page.content|raw }}.

  2. How does Grav determine which template to use? Answer: By the Markdown filename. default.mddefault.html.twig, blog.mdblog.html.twig, etc.

  3. How do you create a template that shows child pages? Answer: Loop over page.children with {% for child in page.children %}.

  4. What's the difference between {% include %} and {% extends %}? Answer: {% extends %} creates a parent-child template relationship with block inheritance. {% include %} inserts a reusable template partial.

  5. Challenge: Create a custom template called gallery.html.twig that displays all images in a page's media folder as a grid. Create a page using this template and add 3 images.

What's Next

You understand Twig. Let's make your site look professional:

Continue to Lesson 6: Themes & Customization — Customize Grav's Quark theme or build your own.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro