Jekyll Liquid Templating: A Beginner's Complete Guide
Learn Jekyll's Liquid template language from scratch — objects, tags, filters, control flow, loops, and practical patterns used in real Jekyll themes.
Liquid is the template language Jekyll uses to make HTML dynamic. It was created by Shopify and is also used in many other platforms. In Jekyll, Liquid lets you output variables, loop through posts, check conditions, and transform data — all within your HTML files.
If you have ever seen {{ page.title }} or {% for post in site.posts %} in a Jekyll theme, that is Liquid. This guide covers everything you need to know to read, write, and customise Liquid templates.
The three types of Liquid syntax
Liquid uses three distinct delimiters, each with a different purpose:
1. Output tags {{ }}
Double curly braces output the value of a variable or expression:
{{ page.title }}
{{ site.description }}
{{ post.date | date: "%B %-d, %Y" }}
Whatever is inside {{ }} is evaluated and printed to the page.
2. Logic tags {% %}
Curly brace with percent signs execute logic — control flow, loops, assignments. They do not output anything themselves:
{% if page.featured %}
<span class="badge">Featured</span>
{% endif %}
{% for post in site.posts %}
<h2>{{ post.title }}</h2>
{% endfor %}
{% assign author = site.data.authors | where: "name", page.author | first %}
3. Comment tags {% comment %}
Comments are not rendered in output:
{% comment %}
This is a Liquid comment.
Useful for notes to yourself or temporarily disabling code.
{% endcomment %}
Liquid objects in Jekyll
Objects are the data sources available in Liquid. Jekyll provides several built-in objects.
site
Site-wide data from _config.yml and the Jekyll build:
{{ site.title }} → "JekyllHub"
{{ site.url }} → "https://jekyllhub.com"
{{ site.description }} → your site description
{{ site.posts }} → array of all posts
{{ site.pages }} → array of all pages
{{ site.themes }} → array of theme collection items
{{ site.data.navigation }} → contents of _data/navigation.yml
{{ site.time }} → build time
Any key in _config.yml becomes a site.* variable:
# _config.yml
sendy_list_id: "abc123"
{{ site.sendy_list_id }} → "abc123"
page
Data about the current page being rendered:
{{ page.title }} → post or page title
{{ page.url }} → URL of the current page
{{ page.date }} → date (for posts)
{{ page.content }} → rendered HTML content
{{ page.excerpt }} → post excerpt
{{ page.categories }} → array of categories
{{ page.tags }} → array of tags
{{ page.author }} → value from front matter
{{ page.image }} → any custom front matter variable
Every front matter key on the current page is available as page.keyname.
layout
Data from the layout file’s front matter:
{{ layout.title }}
{{ layout.sidebar }}
Rarely used, but available if your layout has its own front matter variables.
content
The rendered content of the current page, available only inside layout files:
<!-- _layouts/default.html -->
<main>
{{ content }}
</main>
forloop (inside loops)
Available inside {% for %} loops:
{% for post in site.posts %}
{{ forloop.index }} → 1-based position (1, 2, 3...)
{{ forloop.index0 }} → 0-based position (0, 1, 2...)
{{ forloop.first }} → true on first iteration
{{ forloop.last }} → true on last iteration
{{ forloop.length }} → total number of items
{{ forloop.rindex }} → reverse index (counts down)
{% endfor %}
Variables: assign and capture
Create your own variables with assign:
{% assign greeting = "Hello, world!" %}
{{ greeting }}
{% assign featured = site.posts | where: "featured", true %}
{% assign post_count = site.posts | size %}
{% assign site_url = site.url | append: site.baseurl %}
Use capture to build a string from multiple lines:
{% capture author_link %}
<a href="/authors/{{ page.author | slugify }}/">{{ page.author }}</a>
{% endcapture %}
{{ author_link }}
capture is useful when you need to build a complex string and use it multiple times, or pass it to an include.
Filters
Filters transform the value on the left using a pipe |:
{{ page.title | upcase }}
→ "JEKYLL LIQUID TEMPLATING BASICS"
{{ page.date | date: "%B %-d, %Y" }}
→ "August 4, 2026"
{{ site.posts | size }}
→ 81
{{ "/about/" | relative_url }}
→ "/about/" (or "/subdir/about/" if baseurl is set)
{{ site.url | append: page.url }}
→ "https://jekyllhub.com/blog/my-post/"
Filters can be chained:
{{ page.title | downcase | replace: " ", "-" | truncate: 30 }}
For a full filter reference, see the Jekyll Liquid Filters Cheatsheet.
Control flow: if, elsif, else, unless
{% if page.featured %}
<span class="badge badge--featured">Featured</span>
{% elsif page.trending %}
<span class="badge badge--trending">Trending</span>
{% else %}
<span class="badge">Standard</span>
{% endif %}
unless is the opposite of if — true when the condition is false:
{% unless page.hide_toc %}
{% include toc.html %}
{% endunless %}
Comparison operators
{% if page.price == 0 %}Free{% endif %}
{% if page.price > 0 %}Paid{% endif %}
{% if page.price != 0 %}Not free{% endif %}
{% if page.stars >= 1000 %}Popular{% endif %}
{% if page.title contains "Jekyll" %}...{% endif %}
Logical operators
{% if page.featured and page.price == 0 %}
Free and featured!
{% endif %}
{% if page.category == "Tutorial" or page.category == "Guide" %}
Educational content
{% endif %}
Checking if a variable exists
{% if page.image %}
<img src="{{ page.image | relative_url }}" alt="{{ page.title }}">
{% endif %}
{% if page.tags != empty %}
<ul>
{% for tag in page.tags %}
<li>{{ tag }}</li>
{% endfor %}
</ul>
{% endif %}
Loops: for
Loop over arrays with {% for %}:
{% for post in site.posts %}
<article>
<h2><a href="{{ post.url }}">{{ post.title }}</a></h2>
<time>{{ post.date | date: "%B %-d, %Y" }}</time>
<p>{{ post.excerpt }}</p>
</article>
{% endfor %}
Loop modifiers
{% comment %} Limit to first 6 items {% endcomment %}
{% for post in site.posts limit: 6 %}
...
{% endfor %}
{% comment %} Skip the first 3 items {% endcomment %}
{% for post in site.posts offset: 3 %}
...
{% endfor %}
{% comment %} Reverse the order {% endcomment %}
{% for post in site.posts reversed %}
...
{% endfor %}
{% comment %} First 6, skip the first 3 {% endcomment %}
{% for post in site.posts limit: 6 offset: 3 %}
...
{% endfor %}
else in for loops
The else clause runs when the array is empty:
{% for theme in site.themes %}
<div class="card">{{ theme.title }}</div>
{% else %}
<p>No themes found.</p>
{% endfor %}
Looping over a range of numbers
{% for i in (1..5) %}
<span>{{ i }}</span>
{% endfor %}
→ 1 2 3 4 5
break and continue
{% for post in site.posts %}
{% if forloop.index > 5 %}
{% break %}
{% endif %}
{{ post.title }}
{% endfor %}
{% for post in site.posts %}
{% unless post.featured %}
{% continue %}
{% endunless %}
{{ post.title }} ← only featured posts reach here
{% endfor %}
case / when
An alternative to long if/elsif chains:
{% case page.category %}
{% when "Tutorial" %}
<span class="badge badge--tutorial">Tutorial</span>
{% when "Comparison" %}
<span class="badge badge--comparison">Comparison</span>
{% when "Themes" %}
<span class="badge badge--themes">Themes</span>
{% else %}
<span class="badge">Article</span>
{% endcase %}
Working with arrays
Filtering arrays
{% assign free_themes = site.themes | where: "price", 0 %}
{% assign featured_posts = site.posts | where: "featured", true %}
{% assign tutorial_posts = site.posts | where: "category", "Tutorial" %}
where_exp for more complex filtering:
{% assign recent_posts = site.posts | where_exp: "post", "post.date > '2026-01-01'" %}
{% assign popular = site.themes | where_exp: "theme", "theme.stars > 1000" %}
Sorting arrays
{% assign themes_by_stars = site.themes | sort: "stars" | reverse %}
{% assign posts_by_title = site.posts | sort: "title" %}
Grouping arrays
{% assign posts_by_category = site.posts | group_by: "category" %}
{% for group in posts_by_category %}
<h2>{{ group.name }}</h2>
{% for post in group.items %}
<a href="{{ post.url }}">{{ post.title }}</a>
{% endfor %}
{% endfor %}
Getting first/last items
{% assign latest_post = site.posts | first %}
{{ latest_post.title }}
{% assign oldest_post = site.posts | last %}
{{ oldest_post.title }}
raw tag
Prevent Liquid from processing a block — essential when writing about Liquid in a Jekyll blog:
This {{ variable }} will not be processed by Liquid.
{% if true %}This tag will not execute.{% endif %}
Use `` whenever you need to display Liquid code examples.
Whitespace control
Liquid tags add blank lines to output. Use - to strip whitespace:
{% raw %}
{%- for post in site.posts -%}
<li>{{ post.title }}</li>
{%- endfor -%}
The - inside the tag delimiters strips all whitespace (including newlines) before and after the tag.
Practical patterns
Include with a fallback
<meta name="description" content="{{ page.description | default: site.description }}">
Conditional class
<li class="nav-item{% if page.url == item.url %} nav-item--active{% endif %}">
Truncate excerpt
<p>{{ post.excerpt | strip_html | truncatewords: 30 }}</p>
Absolute URL
<meta property="og:url" content="{{ page.url | absolute_url }}">
Build a comma-separated list
{{ page.tags | join: ", " }}
→ "jekyll, tutorial, liquid"
Check if a string contains a word
{% if page.content contains "Jekyll" %}
This post mentions Jekyll.
{% endif %}
Liquid is straightforward once you understand the three delimiter types and the key objects (site, page, content). Most Jekyll template work uses a small subset of the full Liquid language — the patterns above cover the vast majority of what you will encounter in any theme.
Why Liquid is well-suited to static site generation
Liquid’s design is intentionally restrictive compared to languages like Ruby, PHP, or JavaScript. You cannot execute arbitrary code, make network requests, write to files, or access the file system from a Liquid template. This restriction is a feature, not a limitation.
For static site generation, the restriction ensures that templates are predictable, deterministic, and safe. The same template with the same data produces the same output every time — no hidden state, no side effects, no security vulnerabilities from malicious template injection. This is why Liquid is used not only in Jekyll but also in Shopify, Craft CMS, and several other platforms where template safety matters.
The trade-off is that some operations you might reach for in a full programming language require workarounds in Liquid. Computing a reading time, reformatting a data structure, or performing complex mathematical operations are all possible but sometimes verbose. When a Liquid workaround becomes too complex, that is typically a signal to use a Jekyll plugin (written in Ruby) to do the computation and expose the result as a variable or filter.
Liquid performance in large Jekyll sites
Liquid templates are compiled and executed during the Jekyll build. For most sites, build time is dominated by the number of pages being rendered, not the complexity of individual templates. A blog with 50 posts builds fast regardless of template complexity; a site with 2,000 posts builds slowly even with simple templates.
That said, a few Liquid patterns are significantly more expensive than others.
Nested loops with filtering. A {% for post in site.posts %} loop that contains an inner {% for tag in site.tags %} loop creates O(n×m) iterations. For a site with 200 posts and 100 tags, this is 20,000 iterations per page rendered. Restructure your data beforehand with assign and where outside the inner loop wherever possible.
Repeated include calls. Each {% include %} call parses and executes the include file. Calling {% include card.html %} inside a loop of 100 items processes the card file 100 times. This is fine for most sites, but when build times are slow, optimising which includes are called in hot loops is an effective lever.
Unfiltered site.posts on every page. If your layout includes a “related posts” section that filters site.posts on every single rendered page, it runs that filtering operation once per page. With 200 posts, that is 200 executions of the filter. Pre-compute with assign in the layout or consider a plugin-based solution.
For most Jekyll sites under 500 pages, none of these matter — build times stay under 30 seconds regardless. They become relevant at scale, where small Liquid inefficiencies compound into meaningful build time increases.
Liquid vs JavaScript for interactivity
Liquid runs at build time and produces static HTML. JavaScript runs in the browser after the page loads. Understanding this distinction prevents a common mistake: trying to use Liquid for things that require runtime behaviour.
Liquid is appropriate for: filtering posts by category on an archive page (the filter runs at build time, producing separate archive pages), rendering different layouts based on front matter, generating navigation from a data file, and transforming variables for display.
JavaScript is appropriate for: responding to user actions (clicks, searches, form input), loading data asynchronously, animating elements, and any operation that depends on user context (browser storage, viewport size, authentication state).
The boundary is the browser. Anything that needs to respond to what the user does requires JavaScript. Anything that depends only on your content and configuration can be Liquid.
The best Jekyll sites use both: Liquid renders fast, SEO-friendly, pre-built HTML, and JavaScript adds interactivity on top without requiring the content to be rendered client-side. Alpine.js is particularly well-suited to this pattern — it lets you add reactivity to Liquid-rendered HTML with minimal overhead.
Template inheritance and the layout chain
Jekyll’s layout system is built on Liquid and implements template inheritance. A post uses layout: post, post.html uses layout: default, and default.html is the root template. Jekyll renders these from the inside out: the post’s Markdown becomes HTML, which becomes content in post.html, which becomes content in default.html.
This chain can be as deep as you need. A complex theme might have layout: home → layout: default, while blog posts have layout: post → layout: default, and documentation pages have layout: doc → layout: with-sidebar → layout: default. Each level adds its wrapper HTML around the {{ content }} block from the level below.
The page variable is always the same throughout the chain — it refers to the original document (the post or page being rendered), not any intermediate layout. Only the layout variable changes — it refers to the front matter of the current layout file, not the page.
Understanding this means you can design layouts that adapt to the page’s front matter at every level of the chain. A default.html layout can check {% if page.full_width %} to render a full-width variant, even though the logic lives in the root template rather than in each individual page file.
Includes as a component system
Jekyll includes are Liquid’s answer to component-based design. Each include is a reusable fragment of HTML that can be inserted into any template and can accept parameters.
The include system does not have the full power of modern component frameworks (no state, no event handling, no lifecycle hooks), but it covers the vast majority of what static site templates need: reusable cards, navigation items, form fragments, meta tag blocks, and schema markup.
Effective use of includes follows a few principles. Keep includes focused — an include should do one thing. Avoid deeply nested includes (more than two or three levels) because they become hard to trace when debugging. Accept parameters for all variable content rather than relying on global variables where possible — this makes the include’s contract explicit.
For the portions of your Jekyll site that need true interactivity, Alpine.js works seamlessly alongside Liquid includes. Your Liquid include renders the HTML structure; Alpine attributes (x-data, x-show, x-model) layer the interactive behaviour on top. The two systems are designed for exactly this kind of collaboration — Liquid for structure, Alpine for interactivity — making it possible to build sophisticated, interactive interfaces without a JavaScript rendering framework.
Learning Liquid thoroughly pays dividends whenever you work with a Jekyll theme — reading someone else’s templates, debugging unexpected output, or building your own layouts from scratch. The consistent syntax, the clear data model, and the deliberate limitations all contribute to templates that are readable and maintainable. Once the patterns in this guide are familiar, reading any Jekyll theme’s templates is straightforward, regardless of the theme’s complexity.