Grav Twig Templating — Build Dynamic Page Templates
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
What Twig filter prevents HTML escaping when rendering page content? Answer:
|raw— e.g.,{{ page.content|raw }}.How does Grav determine which template to use? Answer: By the Markdown filename.
default.md→default.html.twig,blog.md→blog.html.twig, etc.How do you create a template that shows child pages? Answer: Loop over
page.childrenwith{% for child in page.children %}.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.Challenge: Create a custom template called
gallery.html.twigthat 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