If you’re a developer diving into HubSpot CMS development for the first time, the HubL templating language is where you’ll spend most of your time. HubL syntax will feel familiar if you’ve worked with Jinja2, Twig, or Liquid — it’s a server-side HubSpot template language with its own set of quirks and strengths. Understanding HubL variables, filters, and module patterns is essential for any HubSpot developer building a custom HubSpot theme. This guide is practical, code-heavy, and designed for developers who already know how to build websites but need to get productive in HubL fast. Let’s get into it.
HubL Basics: The Syntax Foundation
HubL uses three types of delimiters, and if you’ve used Jinja2 or Twig, you’ll recognize them immediately:
{{ }}— Expression output (prints a value){% %}— Statement tags (logic, loops, conditionals){# #}— Comments (not rendered in output)
Here’s the most basic example — outputting a variable:
{{ content.name }}
{{ content.meta_description }}
{{ request.path }}
HubSpot provides a rich set of predefined variables. The content object gives you access to page-level data (title, meta description, publish date, author, etc.). The request object gives you info about the current HTTP request (path, query parameters, domain). And hub_id gives you the portal ID.
Variables and Data Types
You can set variables using the set tag:
{% set my_variable = "Hello World" %}
{% set number_value = 42 %}
{% set is_active = true %}
{% set my_list = ["item1", "item2", "item3"] %}
{% set my_dict = {"key": "value", "name": "Test"} %}
HubL supports strings, numbers, booleans, lists (arrays), and dictionaries (objects). One thing that trips up newcomers: HubL variables set inside a block (like inside an if statement or for loop) are scoped to that block by default. If you need a variable to persist outside a block, set it before the block starts.
Filters: Transforming Output
Filters modify values before they’re rendered. You chain them with the pipe character:
{{ content.name|upper }}
{{ content.name|truncatewords(10) }}
{{ my_date|datetimeformat("%B %d, %Y") }}
{{ my_string|replace("old", "new") }}
{{ my_list|sort }}
{{ content.post_body|striptags|truncatewords(30) }}
Some of the most useful filters you’ll reach for daily:
escape/e— HTML-encode a string (important for user input)truncatewords— Cut text to N wordsdatetimeformat— Format dates (similar to Python’s strftime)default— Provide a fallback value:{{ variable|default("fallback") }}selectattr— Filter a list of dicts by attributemap— Extract a specific attribute from a list of objectsjoin— Join a list into a stringpprint— Debug output (shows variable structure in development)
Conditionals: if, elif, else
Straightforward if you know any templating language:
{% if content.template_path == "templates/landing-page.html" %}
<div class="landing-layout">
{{ content.post_body }}
</div>
{% elif content.template_path == "templates/blog-post.html" %}
<article class="blog-layout">
{{ content.post_body }}
</article>
{% else %}
<div class="default-layout">
{{ content.post_body }}
</div>
{% endif %}
You can use and, or, and not for compound conditions. The is keyword works for type testing: {% if variable is string %}, {% if variable is number %}, etc. And the in keyword checks membership: {% if "value" in my_list %}.
A pattern I use constantly — checking if a module field has content before rendering its wrapper:
{% if module.subtitle %}
<p class="section-subtitle">{{ module.subtitle }}</p>
{% endif %}
This keeps your HTML clean. No empty tags, no unnecessary wrappers.
Loops: Iterating Over Data
The for loop is your workhorse for rendering lists, blog posts, repeating module fields, and more:
{% for item in module.features %}
<div class="feature-card">
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
</div>
{% endfor %}
HubL gives you a loop object inside for loops with useful properties:
loop.index— Current iteration (1-based)loop.index0— Current iteration (0-based)loop.first— True if first iterationloop.last— True if last iterationloop.length— Total number of items
Practical example — rendering a grid with different classes for the first item:
{% for post in contents %}
<article class="post-card {% if loop.first %}post-card--featured{% endif %}">
<h2>{{ post.name }}</h2>
<p>{{ post.post_body|striptags|truncatewords(25) }}</p>
<a href="{{ post.absolute_url }}">Read more</a>
</article>
{% endfor %}
Custom Modules: Where HubL Gets Powerful
If templates are the skeleton of a HubSpot theme, custom modules are the muscles. A module is a reusable content component with its own fields (defined in a JSON schema), its own HubL template, and its own CSS/JS. I wrote a complete developer guide for HubSpot custom modules that goes deep on architecture and best practices, but here’s the practical HubL side.
In a module’s HubL file, you access field values through the module object:
{# module.html for a CTA banner module #}
<section class="cta-banner {% if module.style == 'dark' %}cta-banner--dark{% endif %}">
<div class="cta-banner__inner">
{% if module.heading %}
<h2 class="cta-banner__heading">{{ module.heading }}</h2>
{% endif %}
{% if module.text %}
<p class="cta-banner__text">{{ module.text }}</p>
{% endif %}
{% if module.button_text and module.button_link %}
<a href="{{ module.button_link.url.href }}"
class="cta-banner__button"
{% if module.button_link.open_in_new_tab %}target="_blank" rel="noopener"{% endif %}>
{{ module.button_text }}
</a>
{% endif %}
</div>
</section>
Notice the pattern: always check if a field has content before rendering its markup. This is crucial for a clean editing experience — editors shouldn’t see empty wrappers when they leave optional fields blank.
Macros: DRY Up Your Templates
Macros are reusable template functions. Think of them like PHP functions or Twig macros — you define them once and call them wherever you need them:
{# Define a macro #}
{% macro render_button(text, url, style="primary", new_tab=false) %}
<a href="{{ url }}"
class="btn btn--{{ style }}"
{% if new_tab %}target="_blank" rel="noopener noreferrer"{% endif %}>
{{ text }}
</a>
{% endmacro %}
{# Use it #}
{{ render_button("Get Started", "/contact/", "primary") }}
{{ render_button("Learn More", "/about/", "secondary", true) }}
You can also import macros from external files, which is great for shared components:
{% from "../macros/buttons.html" import render_button %}
{{ render_button("Contact Us", "/contact/") }}
I typically create a macros/ directory in the theme with files like buttons.html, icons.html, schema.html (for structured data), and social.html. This keeps the templates clean and eliminates duplicate code. If you want to see how this fits into a full theme architecture, check out my guide on building a custom HubSpot CMS theme from scratch.
HubDB: Dynamic Data Without a Backend
HubDB is HubSpot’s built-in relational data store, and you access it through HubL. Think of it as a simple database table that you query in your templates:
{% set rows = hubdb_table_rows(TABLE_ID, "orderBy=name&limit=10") %}
{% for row in rows %}
<div class="team-member">
<h3>{{ row.name }}</h3>
<p>{{ row.role }}</p>
<p>{{ row.bio }}</p>
</div>
{% endfor %}
HubDB is great for team directories, location listings, product feature tables, event calendars, and any other structured content that doesn’t fit neatly into the standard page/blog model. You can filter, sort, and paginate results directly in HubL.
Blog-Specific HubL Functions
If you’re building a blog on HubSpot CMS, you’ll use these functions constantly:
{# Get recent blog posts #}
{% set recent_posts = blog_recent_posts("default", 5) %}
{# Get posts by tag #}
{% set tagged_posts = blog_recent_tag_posts("default", "marketing", 3) %}
{# Blog post loop on listing page #}
{% for post in contents %}
<article>
<h2><a href="{{ post.absolute_url }}">{{ post.name }}</a></h2>
<time datetime="{{ post.publish_date|datetimeformat('%Y-%m-%d') }}">
{{ post.publish_date|datetimeformat('%B %d, %Y') }}
</time>
<p>{{ post.post_list_content|striptags|truncatewords(30) }}</p>
</article>
{% endfor %}
Practical Patterns I Use in Every Project
Let me share some patterns that I’ve found essential in production HubSpot themes:
Conditional CSS Classes
{% set section_classes = ["section"] %}
{% if module.background_color %}
{% do section_classes.append("section--" ~ module.background_color) %}
{% endif %}
{% if module.spacing == "large" %}
{% do section_classes.append("section--spacious") %}
{% endif %}
<section class="{{ section_classes|join(' ') }}">
{# content #}
</section>
Safe Image Rendering
{% if module.image.src %}
<img src="{{ module.image.src }}"
alt="{{ module.image.alt }}"
width="{{ module.image.width }}"
height="{{ module.image.height }}"
loading="{{ 'eager' if module.above_fold else 'lazy' }}">
{% endif %}
Structured Data with Macros
{% macro article_schema(post) %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "{{ post.name|escape }}",
"datePublished": "{{ post.publish_date|datetimeformat('%Y-%m-%dT%H:%M:%S') }}",
"author": {
"@type": "Person",
"name": "{{ post.blog_author.display_name|escape }}"
}
}
</script>
{% endmacro %}
Debugging Tips
A few tricks that will save you time:
{{ variable|pprint }}— Dumps the variable structure so you can see what you’re working with- Add
?hsDebug=trueto any page URL to see detailed HubL rendering info - Use the HubSpot CLI (
hs watch) for live reloading during development - The design manager’s preview pane is useful for quick module testing, but always verify in a real page context
- When something isn’t rendering, check for typos in field names — HubL silently returns empty strings for undefined variables instead of throwing errors
Coming from WordPress or Twig
If you’re a WordPress developer exploring HubSpot, here’s a quick mental mapping. For a broader comparison of the two platforms, check out my article on custom modules in HubSpot CMS.
- WordPress
get_template_part()= HubL{% include %} - WordPress custom fields (ACF) = HubSpot module fields (JSON schema)
- WordPress
WP_Query= HubLblog_recent_posts()and HubDB queries - WordPress
the_content()= HubL{{ content.post_body }} - Twig
{% block %}= HubL{% block %}(works the same way) - Twig
{% extends %}= HubL{% extends %}(template inheritance)
The biggest mindset shift: in HubSpot, modules handle most of the heavy lifting that plugins and shortcodes handle in WordPress. There’s no plugin ecosystem — everything custom is built as modules within your theme.
Wrapping Up
HubL is a solid templating language that gets the job done without trying to be more than it needs to be. If you know Jinja2 or Twig, you’ll be productive in HubL within a day. The key differences are in the CMS-specific functions (blog queries, HubDB, module system) rather than the language syntax itself.
Start with the basics, build a few custom modules, and experiment with macros and HubDB. Once you’re comfortable with the patterns in this guide, you’ll be ready to tackle a full theme build. For that, head over to my step-by-step guide for building a custom HubSpot CMS theme — it picks up right where this article leaves off.