📘 HubSpot CMS Development Textbook — 2026 Edition
Chapter 3

Template design/implementation pattern

HubSpot CMS development is determined by "template design." Systematizes implementation patterns from inheritance structure, DnD area, partial usage, complete reference of HubL variables, to blogs, LPs, and error pages. Contains best practices derived from analysis of actual projects (964 templates).

🎯 Target level:Intermediate to advanced
⏱ Estimated reading completion:90-120 minutes
🔗 Previous chapter:Chapter 2 HubL Basics

Contents of this chapter

  1. Template types and role maps
  2. Template inheritance structure (extends/block)
  3. Complete implementation of base.html
  4. Design and implementation of DnD (drag and drop) area
  5. How to use partials and global partials
  6. HubL Variables, Tags, Filters, and Functions Complete Reference
  7. Implementation of blog list template
  8. Implementing a blog post template
  9. Implementation of LP/error page template
  10. Advanced implementation patterns (macro, is_in_editor, JSON passing)
Section 3-1

Template types and role maps

HubSpot CMS templatesThe “skeleton” of the pageDefine. When marketers create a page, they choose which template to use. The starting point for design is to first organize the types and roles of templates.

Template typeFile name conventionsApplications/Features
General purpose page page.html Company profile, service introduction, recruitment, etc. With header/footer. Place modules freely in the DnD area.
landing page landing-page.html LP for advertising and campaigns. Minimize or none header/footer. Layout to maximize conversions.
Blog list blog-listing.html List of blog articles. Linked to HubSpot blog. Implemented pagination tag filter.
Blog article blog-post.html Blog article details page. Linked to HubSpot blog. Implement structured data, previous and subsequent articles, and related articles.
Search results search-results.html Site search results page. Integrates with HubSpot's search API.
error page error-page.html Error pages such as 404. No DnD/Simple configuration.
Password protected page password-prompt.html Membership/limited content access control (Enterprise).
📊 Scale of actual project

In the analysis of the actual project code base,964 templatehas been confirmed. This is the scale of managing multiple themes, clients, and brand variations in one portal. In a typical configuration for a single site6-12 templatesis standard.


Section 3-2

Template inheritance structure (extends/block)

HubSpot CMS templatesInheritanceIt has a mechanism of {% extends %} Specify the base template with {% block %} Defines the area to be overwritten. With this mechanism, changes to the header, footer, and head tag can be reflected in all templates in one file.

Inheritance tree (extracted from actual project)

layouts/base.html ← All inheritance sources. Define head, header, footer, and standard tags ├── templates/page.html ← General purpose page. Have a DnD area ├── templates/landing-page.html ← LP. Minimize header/footer blocks ├── templates/blog-listing.html ← Blog list. has a contents loop ├── templates/blog-post.html ← Article details. Constructed mainly around the content variable ├── templates/search-results.html ← Search results page └── templates/error-page.html ← 404 etc. Simple configuration

Basics of {% extends %} and {% block %}

HubL — How inheritance works
{# ===== Parent template (layouts/base.html) ===== #}

<!DOCTYPE html>
<html lang="ja">
<head>
  {% block head_meta %}{% endblock %}    {# ← You can add OGP etc. as a child #}
  {{ standard_header_includes }}        {# HubSpot required #}
</head>
<body>
  {% block header %}
    {% include "../partials/header.html" %}
  {% endblock %}

  <main>
    {% block main_content %}{% endblock %}  {# ← Each template implements this #}
  </main>

  {% block footer %}
    {% include "../partials/footer.html" %}
  {% endblock %}

  {% block extra_js %}{% endblock %}      {# ← Page-specific JS #}
  {{ standard_footer_includes }}        {# HubSpot required #}
</body>
</html>{# ===== Child template (templates/page.html) ===== #}

{% extends "../layouts/base.html" %}

{# Add OGP to head_meta block #}
{% block head_meta %}
  <meta property="og:title"   content="{{ content.html_title }}">
  <meta property="og:description" content="{{ content.meta_description }}">{% endblock %}

{# Implement main content in DnD area #}
{% block main_content %}
  {% dnd_area "main_dnd" label="Main content" %}
    {# Default module placement #}
  {% end_dnd_area %}
{% endblock %}

{% block %} design principles

block nameroleHandling in child templates
head_metaPage-specific meta information such as OGP, canonical, structured data, etc.Override if necessary
extra_cssPage-specific CSS links and inline stylesAdd as needed
headerglobal headerOverride to minimize/hide in LP
main_contentPage-specific main contentMust be implemented (required block)
footerglobal footerOverride to minimize in LP
extra_jsPage-specific JS (form initialization, GTM events, etc.)Add as needed

Section 3-3

Complete implementation of base.html

base.html is the inheritance source for all templates. The code you write here will affect all pages. It is possible to accurately implement the head tag, OGP, CSS loading, and placement of HubSpot required tags. It affects the overall quality of the site.

layouts/base.html — complete implementation
{#
  =====================================================
  base.html — where all templates are inherited from
  =====================================================
  [Block list]
    head_meta: Page-specific meta information (OGP, canonical, etc.)
    extra_css : Page-specific CSS
    header: Global header (overridden with LP, etc.)
    main_content: ★ Main content (must be implemented)
    footer: Global footer (overridden with LP, etc.)
    extra_js: page-specific JS
  =====================================================
#}
<!DOCTYPE html>
<html
  lang="{{ content.language|default("ja") }}"
  prefix="og: https://ogp.me/ns#">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  {# ===== Page title ===== #}
  <title>{{ content.html_title }}</title>

  {# ===== meta description ===== #}
  {% set desc = "" %}
  {% if content.meta_description %}
    {% set desc = content.meta_description %}
  {% elif content.post_body %}
    {% set desc = content.post_body|striptags|truncate(110,end="") %}
  {% else %}
    {% set desc = site_settings.meta_description|default("") %}
  {% endif %}
  <meta name="description" content="{{ desc|escape }}">

  {# ===== canonical ===== #}
  <link rel="canonical" href="{{ content.absolute_url }}">

  {# ===== OGP (common part) ===== #}
  <meta property="og:site_name" content="{{ site_settings.company_name|escape }}">
  <meta property="og:locale"    content="ja_JP">
  <meta property="og:url"       content="{{ content.absolute_url }}">
  <meta property="og:title"     content="{{ content.html_title|escape }}">
  <meta property="og:description" content="{{ desc|escape }}">

  {# OGP image (with fallback) #}
  {% set ogp_image = content.featured_image
    |default(site_settings.favicon.src)
    |default(get_asset_url("../images/ogp-default.png")) %}
  <meta property="og:image"  content="{{ ogp_image }}">
  <meta property="og:type"   content="website">

  {# Twitter Card #}
  <meta name="twitter:card"        content="summary_large_image">
  <meta name="twitter:title"       content="{{ content.html_title|escape }}">
  <meta name="twitter:description"  content="{{ desc|escape }}">
  <meta name="twitter:image"       content="{{ ogp_image }}">

  {# ===== Organization structured data (common to all pages) ===== #}
  <script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@type": "Organization",
    "name": "{{ site_settings.company_name|escape }}",
    "url": "{{ site_settings.website_url }}",
    "logo": { "@type": "ImageObject", "url": "{{ get_asset_url("../images/logo.png") }}" }
  }
  </script>

  {# ===== Page-specific meta (added in child template) ===== #}
  {% block head_meta %}{% endblock %}

  {# ===== Global CSS (via CDN/asynchronous loading) ===== #}
  <link rel="preload" as="style"
       href="{{ get_asset_url('../css/main.css') }}"
       onload="this.rel='stylesheet'">
  <noscript>
    <link rel="stylesheet" href="{{ get_asset_url('../css/main.css') }}">
  </noscript>

  {# ===== Expand theme.json value as CSS variable ===== #}
  <style>
    :root {
      --color-primary   : {{ theme.colors.primary_color.color }};
      --color-secondary : {{ theme.colors.secondary_color.color }};
      --color-text      : {{ theme.colors.text_color.color }};
      --font-heading    : '{{ theme.typography.heading_font.font }}', sans-serif;
      --font-body       : '{{ theme.typography.body_font.font }}', sans-serif;
      --container-max   : {{ theme.layout.container_max_width }}px;
      --section-padding : {{ theme.layout.section_padding_v }}px;
      --border-radius   : {{ theme.layout.border_radius }}px;
    }
  </style>

  {# ===== Page-specific CSS (added in child template) ===== #}
  {% block extra_css %}{% endblock %}

  {# ===== HubSpot required tag (placed at the end of head) ===== #}
  {{ standard_header_includes }}

</head>

<body>

  {# ===== Skip navigation (accessibility) ===== #}
  <a href="#main-content" class="skip-link">メインコンテンツへスキップ</a>

  {# ===== Global header (can be overridden in children) ===== #}
  {% block header %}
    {% include "../partials/header.html" %}
  {% endblock %}

  {# ===== Main content (implemented by child template) ===== #}
  <main id="main-content" role="main" tabindex="-1">
    {% block main_content %}{% endblock %}
  </main>

  {# ===== Global footer (can be overridden in children) ===== #}
  {% block footer %}
    {% include "../partials/footer.html" %}
  {% endblock %}

  {# ===== Global JS ===== #}
  <script src="{{ get_asset_url('../js/main.js') }}" defer></script>

  {# ===== Page-specific JS (added in child template) ===== #}
  {% block extra_js %}{% endblock %}

  {# ===== HubSpot required tag (placed at the end of body) ===== #}
  {{ standard_footer_includes }}

</body>
</html>
⚠️ Never omit standard_header_includes and standard_footer_includes

{{ standard_header_includes }}(in head) and {{ standard_footer_includes }}(body end) is Automatically insert essential scripts for HubSpot tracking, forms, live chat, etc. If you omit this, serious problems will occur such as the form not working or analysis data not being collected. Be sure to include both in all templates.


Section 3-4

Design and implementation of DnD (drag and drop) area

The DnD area is an area where marketers can freely add and rearrange modules using the page editor. "Marketers can freely place content within the layout designed by engineers." That is the essence of DnD.

DnD structure (4 layers)

dnd_area (entire area)
dnd_section (section)
dnd_column (column)
dnd_column (left)
🧩 module (hero banner)
🧩 module (text)
dnd_column (right)
🧩 module (image)
dnd_column (full width)
🧩 module (CTA banner)

Implementation of DnD

templates/page.html — Complete implementation pattern for DnD area
{% extends "../layouts/base.html" %}

{% block main_content %}

  {# ===== DnD area definition =====
     label: the name displayed in the page editor
  ===================================================== #}
  {% dnd_area "main_dnd"
    label="Main content" %}

    {# --- Default content (initial display when creating a new page) --- #}
    {% dnd_section %}
      {% dnd_column %}
        {% dnd_module
          path="../modules/hero-banner"
          label="Hero Banner"
          heading="Please enter a heading"
          subheading="Please enter a subheading"
        %}
      {% end_dnd_column %}
    {% end_dnd_section %}

  {% end_dnd_area %}

{% endblock %}
HubL — dnd_section style settings (margins/background)
{# Style parameters can be passed to dnd_section
   However, these are settings that can be overwritten in the page editor.
   If you want to force a fixed appearance, it is safer to control it with CSS.
#}
{% dnd_section
  vertical_alignment="MIDDLE"
  padding={{ {
    "top"   : { "value": 80,  "units": "px" },
    "bottom": { "value": 80,  "units": "px" },
    "left"  : { "value": 20,  "units": "px" },
    "right" : { "value": 20,  "units": "px" }
  } }}
  background_color={{ { "css": "#f8fafc" } }}%}
  {% dnd_column %}
    {% dnd_module path="../modules/card-grid" %}
  {% end_dnd_column %}
{% end_dnd_section %}

{# ===== Two-column layout section ===== #}
{% dnd_section %}
  {% dnd_column width=7 %}     {# 7/12 column width #}
    {% dnd_module path="../modules/rich-text" %}
  {% end_dnd_column %}
  {% dnd_column width=5 %}     {# 5/12 column width #}
    {% dnd_module path="../modules/form-section" %}
  {% end_dnd_column %}
{% end_dnd_section %}
💡 Design decision to “not set allowed_modules” in actual project

In the code base analysis of the actual project,dnd_area to allowed_modules The limit of Almost no settings were made. this is"Flexibility to place all custom modules on any page" This is a design decision that prioritizes.
If you want to set a limit, write it like this:
{% dnd_area "main" allowed_modules=["../modules/hero-banner", "../modules/card-grid"] %}


Section 3-5

How to use partials and global partials

HubSpot offers multiple ways to reuse files. It is important to use them appropriately depending on the purpose.

methodHubL syntaxApplications/Features
include (partial) {% include "path" %} Load files in a theme statically. Common HTML such as headers and footers. Cannot be edited directly from the management screen.
include_dnd_partial {% include_dnd_partial path="...", context={} %} An extended version that allows you to include while passing the context variable. Used to switch variants (transparent header, etc.).
global_module {% global_module "module_id" %} A global module created in the HubSpot admin screen. Marketers can edit directly from the admin screen.
global_partial {% global_partial path="path" %} Partials that can be edited on the admin screen. Use this when you want to open the header and footer to marketers.

include_dnd_partial + context pattern (confirmed in actual project)

What was distinctive about the actual case was include_dnd_partial to context This is a pattern to pass. Even with the same header, you can switch between variants such as "transparent" and "white background" for each page.

HubL — Header variant with include_dnd_partial + context
{# ===== LP/top page that requires transparent header ===== #}
{% block header %}
  {% include_dnd_partial
    path="../partials/header.html",
    context={ "header_variant": "transparent" }
  %}
{% endblock %}

{# ===== Normal page (no context → default white header) ===== #}
{# {% block header %} in base.html is used as is #}

{# ===== partials/header.html side implementation ===== #}
<header
  class="site-header site-header--{{ header_variant|default("default") }}"
  role="banner">
  {% if header_variant == "transparent" %}
    {# Override CSS variables for transparent headers #}
    <style>
      .site-header--transparent {
        --header-bg   : transparent;
        --header-color: #ffffff;
      }
    </style>
  {% endif %}
  {# ... Implementing navigation ... #}
</header>

Accessible hamburger menu (actual project pattern)

partials/header.html — navigation implementation
<nav
  id="global-nav"
  class="global-nav"
  aria-label="Global navigation"
  aria-hidden="true">

  {% set nav_items = [
    { "label": "service",   "url": "/service"  },
    { "label": "Case study",       "url": "/case"     },
    { "label": "Blog",     "url": "/blog"     },
    { "label": "Company Profile",   "url": "/about"    },
    { "label": "inquiry", "url": "/contact"  }
  ] %}

  <ul class="global-nav__list" role="list">
    {% for item in nav_items %}
      {% set is_current = request.path starts_with item.url %}
      <li class="global-nav__item{% if is_current %} is-current{% endif %}">
        <a
          href="{{ item.url }}"
          class="global-nav__link"
          {% if is_current %}aria-current="page"{% endif %}>
          {{ item.label }}
        </a>
      </li>
    {% endfor %}
  </ul>
</nav>{# Hamburger button (open/close state managed with aria-expanded) #}
<button
  class="hamburger"
  aria-controls="global-nav"
  aria-expanded="false"
  aria-label="Open menu">
  <span aria-hidden="true"></span>
  <span aria-hidden="true"></span>
  <span aria-hidden="true"></span>
</button>

Section 3-6

HubL Variables, Tags, Filters, and Functions Complete Reference

The entire template of the actual project was scanned and extracted.HubL elements actually usedorganize.

Main global variables

content — page/article context variable
content.namePage/article title (management name)
content.html_titleHTML title used for <title> tag
content.meta_descriptionmeta description
content.absolute_urlFull URL of the page (https://...)
content.featured_imageEye-catching image URL (blog article)
content.featured_image_alt_textAlt attribute of featured image
content.post_bodyBlog article body HTML
content.publish_datePublication date and time (Unix timestamp)
content.updatedLast updated date and time
content.tag_listArray of tag objects. Each tag has .name / .slug / .absolute_url
content.blog_post_authorAuthor object (.full_name / .avatar / .bio etc.)
content.languagePage language code (such as "ja")
content.translated_contentMultilingual version of dictionary (used for hreflang implementation)
request — Request information
request.pathCurrent URL path (such as "/blog/article-title")
request.query_dictDictionary of URL query parameters (?tag=xxx → request.query_dict.tag)
request.domaincurrent domain name
request.full_urlcomplete request url
Other important variables
site_settings.company_nameCompany name (from HubSpot settings → website information)
site_settings.website_urlSite root URL
hub_idHubSpot portal ID (used when passing to JS, etc.)
is_in_editorWhether in the page editor (true/false). Used for processing limited to editor time.
theme.xxx.yyytheme.json settings (e.g. theme.colors.primary_color.color)
current_page_numCurrent page number of pagination (blog list, etc.)
last_page_numLast page number for pagination

Frequently used HubL tags

{% module "name" path="..." %}
Place custom modules in templates. Field values ​​can be passed as parameters.
{% module "hero" path="../modules/hero-banner" heading="Title" %}
{% global_module "id" %}
Load the global module created on the management screen. Marketers can edit it on the admin screen.
{% global_module "12345678" %}
{% form form_id="..." %}
Render HubSpot forms server-side. You can pass hidden fields with form_field_values_json.
{% form form_id="xxxx-xxxx" response_redirect="/thanks" %}
{% dnd_area %} / {% end_dnd_area %}
Defining a drag and drop editing area. Place dnd_section → dnd_column → dnd_module inside this.
{% dnd_area "main" label="main content" %}
{% include "path" %}
Statically include another file. Used when loading partials within a theme.
{% include "../partials/header.html" %}
{% require_css %}
Declaration to load external CSS within the module. Automatically insert <link> tag in head.
{% require_css path="{{ get_asset_url("../css/slider.css") }}" %}
{% require_js %}
Declaration to load external JS within the module. Automatically inserts a <script> tag at the end of the body.
{% require_js path="{{ get_asset_url("../js/slider.js") }}" %}
{% macro name(args) %}
Define reusable HubL template functions. You can reuse the same HTML pattern in multiple places.
{% macro card(title, url) %}...{% endmacro %}

Top 10 frequently used filters in actual projects

filterPurposeImplementation example
| truncate(N)truncate string to N characters{{ content.name | truncate(50) }}
| escapeHTML escape (XSS prevention){{ module.text | escape }}
| striptagsRemove HTML tags and convert to plain text{{ content.post_body | striptags | truncate(100) }}
| datetimeformatFormat date and time in specified format{{ content.publish_date | datetimeformat("%Y年%m月%d日") }}
| replace(a, b)String replacement{{ tag.slug | replace("type:", "") }}
| default(val)Specify default value if value is missing{{ module.label | default("詳しく見る") }}
| lowerlowercase{{ tag.slug | lower }}
| uppercapitalization{{ content.language | upper }}
| lengthGet list count{{ module.items | length }}
| pprintDebug output of variable contents{{ module | pprint }}(Deleted in production)

Main functions used in actual projects

functionPurpose
blog_recent_posts(blog_id, count)Get N latest articles of specified blog
blog_recent_posts("default", count)Get the latest articles of default blog
blog_tag_posts(blog_id, tag_slug, count)Get articles with specific tags
blog_total_post_count(blog_id)Get the total number of blog articles
hubdb_table_rows(table, opts)Get records from HubDB table
hubdb_table_row(table, row_id)Get one specific record from HubDB
get_asset_url("path")Get the CDN URL of the assets in the theme (required)
content_by_id(id)Get page object with specified ID

Section 3-7

Implementation of blog list template

templates/blog-listing.html — complete implementation
{% extends "../layouts/base.html" %}

{% block head_meta %}
  {# Add "Nth page" to the description for the second and subsequent pagination pages #}
  {% if current_page_num and current_page_num > 1 %}
    <meta name="robots" content="noindex, follow">
  {% endif %}
  {# Pagination prev/next links #}
  {% if current_page_num > 1 %}
    <link rel="prev" href="{{ blog_page_link(current_page_num - 1) }}">
  {% endif %}
  {% if current_page_num < last_page_num %}
    <link rel="next" href="{{ blog_page_link(current_page_num + 1) }}">
  {% endif %}
{% endblock %}

{% block main_content %}

{# ===== Page header ===== #}
<div class="blog-listing-hero">
  {% if active_tag %}
    <h1>タグ:{{ active_tag.name }}</h1>
  {% elif active_author %}
    <h1>{{ active_author.full_name }} の記事</h1>
  {% else %}
    <h1>ブログ</h1>
  {% endif %}
</div>

<div class="blog-listing container">

  {# ===== Tag filter ===== #}
  {% set all_tags = blog_all_tags("default") %}
  {% if all_tags %}
    <nav class="blog-listing__tags" aria-label="Filter by tag">
      <a
        href="{{ blog_all_posts_url("default") }}"
        class="tag-btn{% if not active_tag %} is-active{% endif %}">すべて</a>
      {% for tag in all_tags %}
        <a
          href="{{ tag.absolute_url }}"
          class="tag-btn{% if active_tag and active_tag.slug == tag.slug %} is-active{% endif %}"
          {% if active_tag and active_tag.slug == tag.slug %}
            aria-current="true"
          {% endif %}>
          {{ tag.name }}
        </a>
      {% endfor %}
    </nav>
  {% endif %}

  {# ===== Article list loop ===== #}
  {% if contents %}
    <ul class="blog-listing__grid" role="list">
      {% for post in contents %}
        <li class="blog-card">
          <a href="{{ post.absolute_url }}" class="blog-card__link">

            {# Thumbnail #}
            {% if post.featured_image %}
              <div class="blog-card__thumb">
                <img
                  src="{{ post.featured_image }}?width=640&format=webp"
                  alt="{{ post.featured_image_alt_text|default(post.name)|escape }}"
                  loading="lazy"
                  decoding="async"
                  width="640" height="360">
              </div>
            {% endif %}

            {# tag (displayed excluding type: prefix) #}
            {% set display_tags = [] %}
            {% for tag in post.tag_list %}
              {% if not tag.slug starts_with "type:" %}
                {% set display_tags = display_tags|list + [tag] %}
              {% endif %}
            {% endfor %}
            {% if display_tags %}
              <div class="blog-card__tags">
                {% for tag in display_tags|slice(0,2) %}
                  <span class="tag-badge">{{ tag.name }}</span>
                {% endfor %}
              </div>
            {% endif %}

            <h2 class="blog-card__title">{{ post.name }}</h2>
            <p  class="blog-card__excerpt">
              {{ post.post_body|striptags|truncate(80,end="…") }}
            </p>
            <time
              class="blog-card__date"
              datetime="{{ post.publish_date|datetimeformat("%Y-%m-%d") }}">
              {{ post.publish_date|datetimeformat("%Y year %m month %d day") }}
            </time>
          </a>
        </li>
      {% endfor %}
    </ul>

    {# ===== Pagination ===== #}
    {% if last_page_num > 1 %}
      <nav class="pagination" aria-label="Page navigation">
        {% if current_page_num > 1 %}
          <a href="{{ blog_page_link(current_page_num - 1) }}"
             class="pagination__btn"
             aria-label="Previous page">← 前へ</a>
        {% endif %}
        {% for page_num in range(1, last_page_num + 1) %}
          {% if page_num == current_page_num %}
            <span class="pagination__num is-current"
                  aria-current="page">{{ page_num }}</span>
          {% else %}
            <a href="{{ blog_page_link(page_num) }}"
               class="pagination__num">{{ page_num }}</a>
          {% endif %}
        {% endfor %}
        {% if current_page_num < last_page_num %}
          <a href="{{ blog_page_link(current_page_num + 1) }}"
             class="pagination__btn"
             aria-label="Next page">次へ →</a>
        {% endif %}
      </nav>
    {% endif %}

  {% else %}
    <p class="blog-listing__empty">記事が見つかりませんでした。</p>
  {% endif %}

</div>{% endblock %}

Section 3-8

Implementing a blog post template

templates/blog-post.html — full implementation of article details
{% extends "../layouts/base.html" %}

{# ===== Article structured data + OGP override ===== #}
{% block head_meta %}
  <meta property="og:type" content="article">
  {% if content.featured_image %}
    <meta property="og:image" content="{{ content.featured_image }}">
  {% endif %}

  {# Article schema #}
  <script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@type": "Article",
    "headline"       : "{{ content.name|escape }}",
    "datePublished"  : "{{ content.publish_date|datetimeformat("%Y-%m-%dT%H:%M:%S") }}+09:00",
    "dateModified"   : "{{ content.updated|datetimeformat("%Y-%m-%dT%H:%M:%S") }}+09:00",
    "image"          : "{{ content.featured_image|default(get_asset_url("../images/ogp-default.png")) }}",
    "author": {
      "@type": "Person",
      "name" : "{{ content.blog_post_author.full_name|default(site_settings.company_name)|escape }}"
    },
    "publisher": {
      "@type": "Organization",
      "name" : "{{ site_settings.company_name|escape }}",
      "logo" : { "@type": "ImageObject", "url": "{{ get_asset_url("../images/logo.png") }}" }
    },
    "mainEntityOfPage": { "@type": "WebPage", "@id": "{{ content.absolute_url }}" }
  }
  </script>

  {# Preloading LCP elements (featured images) #}
  {% if content.featured_image %}
    <link rel="preload" as="image"
         href="{{ content.featured_image }}?width=900&format=webp"
         fetchpriority="high">
  {% endif %}
{% endblock %}

{% block main_content %}

{# ===== Content type determination (tag prefix) ===== #}
{% set ns = namespace(content_type="blog") %}
{% for tag in content.tag_list %}
  {% if tag.slug starts_with "type:" %}
    {% set ns.content_type = tag.slug|replace("type:","") %}
  {% endif %}
{% endfor %}

<article class="post post--{{ ns.content_type }}">

  {# ===== Header ===== #}
  <header class="post__header container">

    {# Tag display (type: prefix excluded) #}
    {% set display_tags = [] %}
    {% for tag in content.tag_list %}
      {% if not tag.slug starts_with "type:" %}
        {% set display_tags = display_tags|list + [tag] %}
      {% endif %}
    {% endfor %}
    {% for tag in display_tags %}
      <a href="{{ tag.absolute_url }}" class="tag-badge">{{ tag.name }}</a>
    {% endfor %}

    <h1 class="post__title">{{ content.name }}</h1>

    <div class="post__meta">
      {% if content.blog_post_author.avatar %}
        <img src="{{ content.blog_post_author.avatar }}"
             alt="{{ content.blog_post_author.full_name }}"
             class="post__author-avatar"
             width="40" height="40" loading="lazy">
      {% endif %}
      <span>{{ content.blog_post_author.full_name }}</span>
      <time datetime="{{ content.publish_date|datetimeformat("%Y-%m-%d") }}">
        {{ content.publish_date|datetimeformat("%Y year %m month %d day") }}
      </time>
    </div>
  </header>

  {# ===== Eye-catching image (LCP element) ===== #}
  {% if content.featured_image %}
    <div class="post__eyecatch">
      <img
        src="{{ content.featured_image }}?width=900&format=webp"
        alt="{{ content.featured_image_alt_text|default(content.name)|escape }}"
        width="900" height="506"
        loading="eager"
        fetchpriority="high"
        decoding="sync">
    </div>
  {% endif %}

  {# ===== Body ===== #}
  <div class="post__body container">
    {{ content.post_body }}
  </div>

  {# ===== Previous/previous article navigation ===== #}
  <nav class="post-nav container" aria-label="Previous and subsequent articles">
    {% if content.prev_post_featured_image or content.prev_post_name %}
      <a href="{{ content.prev_post_url }}" class="post-nav__prev">
        <span>← 前の記事</span>
        <p>{{ content.prev_post_name }}</p>
      </a>
    {% endif %}
    {% if content.next_post_name %}
      <a href="{{ content.next_post_url }}" class="post-nav__next">
        <span>次の記事 →</span>
        <p>{{ content.next_post_name }}</p>
      </a>
    {% endif %}
  </nav>

  {# ===== Related articles (latest 3 with same tag) ===== #}
  {% if content.tag_list %}
    {% set related_tag = display_tags|first %}
    {% if related_tag %}
      {% set related_posts = blog_tag_posts("default", related_tag.slug, 4) %}
      {% set filtered = [] %}
      {% for p in related_posts %}
        {% if p.id != content.id and filtered|length < 3 %}
          {% set filtered = filtered|list + [p] %}
        {% endif %}
      {% endfor %}
      {% if filtered %}
        <section class="post-related container">
          <h2>関連記事</h2>
          <ul class="blog-listing__grid">
            {% for p in filtered %}
              <li><a href="{{ p.absolute_url }}">{{ p.name }}</a></li>
            {% endfor %}
          </ul>
        </section>
      {% endif %}
    {% endif %}
  {% endif %}

</article>{% endblock %}

Section 3-9

Implementation of LP/error page template

Landing page (LP)

LP minimizes headers and footers to focus on conversions. {% block header %} and {% block footer %} override.

templates/landing-page.html — LP template
{% extends "../layouts/base.html" %}

{# ===== LP header (logo only, no navigation) ===== #}
{% block header %}
  <header class="lp-header" role="banner">
    <a href="/" class="lp-header__logo">
      <img
        src="{{ get_asset_url('../images/logo.svg') }}"
        alt="{{ site_settings.company_name }}"
        height="40" loading="eager">
    </a>
  </header>{% endblock %}

{# ===== Footer for LP (copyright only) ===== #}
{% block footer %}
  <footer class="lp-footer" role="contentinfo">
    <p>&copy; {{ "now"|datetimeformat("%Y") }} {{ site_settings.company_name }}</p>
  </footer>{% endblock %}

{# ===== DnD area (freely place sections) ===== #}
{% block main_content %}
  {% dnd_area "lp_dnd" label="LP content" %}
    {% dnd_section %}
      {% dnd_column %}
        {% dnd_module path="../modules/hero-banner" %}
      {% end_dnd_column %}
    {% end_dnd_section %}
  {% end_dnd_area %}
{% endblock %}

Error page (404)

templates/error-page.html — error page
{% extends "../layouts/base.html" %}

{% block main_content %}
<div class="error-page container">
  <h1 class="error-page__code">404</h1>
  <p  class="error-page__message">
    お探しのページは見つかりませんでした。
  </p>
  <p>
    <a href="/" class="btn btn--primary">トップページへ</a>
  </p>

  {# Display the latest articles to encourage people to browse the site #}
  {% set recent = blog_recent_posts("default", 3) %}
  {% if recent %}
    <section>
      <h2>新着記事</h2>
      <ul>
        {% for post in recent %}
          <li><a href="{{ post.absolute_url }}">{{ post.name }}</a></li>
        {% endfor %}
      </ul>
    </section>
  {% endif %}
</div>{% endblock %}

Section 3-10

Advanced implementation patterns

① Reusing templates using macros

Confirmed in actual case {% macro %} It's a pattern. When reusing the same HTML structure in multiple places, you can define and call it like a function.

HubL — Reuse card components with macros
{# ===== Macro definition (place at the beginning of the file or in partials) ===== #}
{% macro render_post_card(post, show_excerpt=true, show_tags=true) %}
  <article class="blog-card">
    {% if post.featured_image %}
      <a href="{{ post.absolute_url }}">
        <img
          src="{{ post.featured_image }}?width=640&format=webp"
          alt="{{ post.featured_image_alt_text|default(post.name)|escape }}"
          loading="lazy" width="640" height="360">
      </a>
    {% endif %}
    <div class="blog-card__body">
      {% if show_tags %}
        {% for tag in post.tag_list|slice(0,2) %}
          {% if not tag.slug starts_with "type:" %}
            <span class="tag-badge">{{ tag.name }}</span>
          {% endif %}
        {% endfor %}
      {% endif %}
      <h3><a href="{{ post.absolute_url }}">{{ post.name }}</a></h3>
      {% if show_excerpt %}
        <p>{{ post.post_body|striptags|truncate(80,end="…") }}</p>
      {% endif %}
    </div>
  </article>{% endmacro %}

{# ===== Call (can be used from anywhere, such as blog list, related articles, etc.) ===== #}
{% for post in contents %}
  {{ render_post_card(post) }}
{% endfor %}

{# Version without excerpts and tags (compact display) #}
{{ render_post_card(post, show_excerpt=false, show_tags=false) }}

② Conditional branching using is_in_editor

is_in_editor when viewed in HubSpot's page editor. true It will be. Confirmed in actual case"Do not load heavy scripts in the editor"It's a pattern.

HubL — Conditional branching with is_in_editor
{# ===== Do not load script in editor =====
   Reason: Animation/external API calls reduce editor performance.
   Because it may be dropped. It does not affect the actual page display.
===================================================== #}
{% if not is_in_editor %}
  <script src="//js.hsforms.net/forms/embed/v2.js"></script>
  <script>
    hbspt.forms.create({
      portalId: "{{ hub_id }}",
      formId  : "FORM_ID",
      target  : "#hs-form-target"
    });
  </script>{% endif %}

{# Show placeholder only in editor #}
{% if is_in_editor %}
  <div class="editor-placeholder">
    [ここにフォームが表示されます]
  </div>{% endif %}

③ Pass JSON data with module_attribute is_json=True

This is an advanced pattern confirmed in actual cases. Pass complex data to the module in JSON format and process it on the JavaScript side.

HubL — module_attribute is_json=True pattern
{# ===== Pass HubDB data to module in JSON =====
   Usage: UI components initialized with JS, such as sliders, maps, charts, etc.
===================================================== #}
{% set rows = hubdb_table_rows("staff",
  { "orderBy": "order", "limit": 20 }) %}

{% set staff_data = [] %}
{% for row in rows %}
  {% set staff_data = staff_data|list + [{
    "name"   : row.name,
    "title"  : row.title,
    "image"  : row.photo.url,
    "profile": row.profile|striptags
  }] %}
{% endfor %}

{# Embed JSON in data-* attributes #}
<div
  id="staff-slider"
  class="staff-slider"
  data-staff='{{ staff_data|tojson }}'>
  {# JS reads data-staff attribute and initializes slider #}
</div>

<script>
  var staffData = JSON.parse(
    document.getElementById('staff-slider').dataset.staff
  );
  // Initialize the slider using staffData
</script>

Section 3-11

Chapter 3 Summary

📌 Key points to keep in mind in this chapter

Inheritance structure is the foundation of quality

Collect elements common to all templates in base.html, and override only the necessary parts of each template using {% extends %} + {% block %}. Changes to head, header, and footer can be completed in one base.html file.

Standard tag placement is an absolute rule

If standard_header_includes (in the head) and standard_footer_includes (at the end of the body) are omitted, forms, tracking, chat, etc. will not work. Must be included in all templates.

DnD is a "balance between freedom and control"

Four-layer structure: dnd_area → dnd_section → dnd_column → dnd_module. It is a design decision whether to restrict placement modules using allowed_modules. In actual projects, there are often no restrictions (all modules can be placed).

include_dnd_partial + context

Even for the same header, you can manage "transparent header" and "regular header" in one file by passing variants using the context variable. Project-specific ideas confirmed in actual projects.

HubL variable priority master

The five core categories are content (page information) / request (request) / site_settings (site settings) / theme (design token) / is_in_editor (editor judgment).

Uses of altitude patterns

macro = reuse of the same HTML structure. is_in_editor = Disable heavy scripts in editor. tojson + data attribute = bridge HubL data to JS. These three occur frequently in actual cases.

Next chapter: Chapter 4 Custom module design/development patterns

We will explain a complete guide to module development, including all field types of fields.json, implementation patterns of module.html, CSS design, and meta information settings.

Go to Chapter 4 →