📘 HubSpot CMS Development Textbook — 2026 Edition
Chapter 7

Form/CTA/conversion design

We will explain the overall picture of conversion design, including two implementation methods for HubSpot forms, CSS override patterns, content type linked form switching, CTA design, thank you pages, and conversion measurement using GA4/GTM.

🎯 Target level:Intermediate to advanced
⏱ Estimated reading completion:60-90 minutes
🔗 Previous chapter:Chapter 6 Data Utilization/Dynamic Content
🆕 March 2026 version — Compatible with old CTA editor abolishment (November 2025)

Contents of this chapter

  1. Overview of HubSpot forms and two implementation methods
  2. Native implementation with HubL form tag
  3. Implementation using JavaScript (hbspt.forms.create)
  4. Complete guide to overriding form CSS
  5. Automatic form switching according to content type
  6. Multi-step forms and pop-up forms
  7. Thank you page and redirect design
  8. CTA design and implementation patterns
  9. Conversion measurement (GA4/GTM collaboration)
  10. Chapter 7 Summary
Section 7-1

Overview of HubSpot forms and two implementation methods

HubSpot forms are directly connected to your CRM, so submitted data is reflected directly into contact records. How to incorporate it into the templateTwo methodsThere are different types depending on the purpose.

HubL form tag method

  • Write directly in HubL template
  • Rendering on the server side
  • Works without JS (direct HTML output of forms)
  • Simple implementation/Fixed placement in template
  • Redirect URL can be passed from HubL
  • Lots of inline CSS (needs to be overwritten)

JS hbspt.forms.create method

  • Dynamically generate form in DOM with JavaScript
  • Render on client side
  • Form ID can be dynamically switched using JS
  • Ideal for switching content types
  • You can use the post-submit callback (onFormSubmit)
  • Requires loading external script

How to check the form ID

HubSpot's form ID can be found on the admin screen. Select a form from "Marketing → Forms", of the URL app.hubspot.com/forms/PORTAL_ID/FORM_ID/edit You can check it here. Or check the embed code from the form's "Share" tab.

PurposeRecommended implementation methodreason
Fixed page form sectionHubL form tagSimple and can be written directly in the template
Switch forms by content typeJS hbspt.forms.createYou can receive form ID from HubL variable and change it dynamically.
Modal popup formJS hbspt.forms.createEasy to combine with DOM manipulation
I want to do my own processing after sendingJS hbspt.forms.createonFormSubmit callback can be used
landing pageEither is possibleChoose according to your requirements

Section 7-2

Native implementation with HubL form tag

HubL's {% form %} Tags are the simplest form implementation method. Since the form HTML is generated on the server side, Works even in environments where JavaScript is disabled.

HubL — Basic implementation of form tag
{# Simplest implementation #}
{% form
  form_id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
%}
HubL — all form tag options
{% form
  {# ===== Required ===== #}
  form_id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

  {# ===== Redirect settings ===== #}
  response_redirect="https://example.com/thanks"
  {# Redirect URL after submission. If not specified, settings from the HubSpot admin screen will be used #}

  {# ===== Thanks message (if not redirected) ===== #}
  response_message="<p>Thank you for submitting. A representative will contact you.</p>"

  {# ===== Binding HubSpot cookies to forms (improves tracking accuracy) ===== #}
  submit_button_text="Send"
  {# Button text can be overwritten from HubL #}

  {# ===== Prefill (hidden field) ===== #}
  form_field_values_json='{"lifecyclestage": "lead", "hs_lead_status": "NEW"}'
  {# Auto-set CRM properties on form submission #}
%}

Use form tag from module

form-section.module / module.html
{# Define form field and redirect_url field in fields.json #}

<section class="form-section">
  {% if module.section_title %}
    <h2 class="form-section__title">{{ module.section_title }}</h2>
  {% endif %}
  {% if module.section_desc %}
    <p class="form-section__desc">{{ module.section_desc }}</p>
  {% endif %}

  {% if module.form and module.form.form_id %}
    <div class="form-section__body">
      {% form
        form_id="{{ module.form.form_id }}"
        response_redirect="{{ module.redirect_url|default("") }}"
      %}
    </div>
  {% else %}
    <p class="form-section__notice">
      フォームが設定されていません。モジュールの設定からフォームを選択してください。
    </p>
  {% endif %}
</section>

Automatically set context information with hidden fields

Contain page context information (which page you came from, what type of content, etc.) when submitting a form. By automatically recording in CRM, the quality of handing over to sales and marketers will improve.

HubL — auto-set context with form_field_values_json
{# Determine the content type (tag determination pattern in Chapters 3 and 6) #}
{% 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 %}

{# Pass as hidden field to form #}
{% form
  form_id="YOUR_FORM_ID"
  form_field_values_json='{
    "hs_lead_status"         : "NEW",
    "content_type_custom__c" : "{{ ns.content_type }}",
    "first_conversion_page"  : "{{ content.absolute_url }}",
    "first_conversion_title" : "{{ content.name|escape }}"
  }'
%}
⚠️ form_field_values_json is valid only for fields that exist in the form

form_field_values_json can only pass fields that are set on the form (including hidden fields). If you specify a property name that does not have a corresponding field on the form side, it will be ignored. If you want to pass a value to a custom property in CRM, useAdd it as a hidden field to the form and thenPlease specify.


Section 7-3

Implementation using JavaScript (hbspt.forms.create)

hbspt.forms.create() is a JavaScript API provided by HubSpot. Dynamically generate forms on any DOM element. Since the form ID can be determined at JS execution time, Ideal for dynamic form switching according to content type and user attributes.

Basic implementation

HubL + JS — hbspt.forms.create basic implementation
{# HTML: Form insertion destination element #}
<div id="hs-form-target"></div>{# JS: Form generation #}
<script src="//js.hsforms.net/forms/embed/v2.js"></script>
<script>
  hbspt.forms.create({
    // ===== Required parameters =====
    region  : "na1",   // Region (Japan is usually na1)
    portalId: "{{ hub_id }}",   // Portal ID (obtained from HubL variable)
    formId  : "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",

    // ===== CSS selector to insert =====
    target  : "#hs-form-target",

    // ===== Redirect after sending =====
    redirectUrl: "https://example.com/thanks",

    // ===== Post-send callback =====
    onFormSubmit: function($form) {
      // Executed immediately after sending (before redirect)
      console.log('Form submission completed', $form);
    },

    // ===== Callback after sending completion =====
    onFormSubmitted: function($form, data) {
      // Executed after the thanks message is displayed
      // Used for sending conversions to GA4, etc.
      if (window.gtag) {
        gtag('event', 'generate_lead', {
          event_category: 'form',
          event_label    : document.title
        });
      }
    },

    // ===== Form ready callback =====
    onFormReady: function($form) {
      // Executed immediately after the form's DOM is generated
      // Used for initial processing such as focus on input field
    }
  });
</script>

Dynamically determine form ID by passing HubL variables to JS

blog-post.html — Complete implementation of switching forms based on content type
{# ① Determine content type with HubL #}
{% 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 %}

{# ② Define form ID dictionary and settings in HubL #}
{% set form_config = {
  "blog"       : {
    "id"      : "BLOG_FORM_ID",
    "title"   : "Consultation/Inquiry",
    "desc"    : "If you have any questions about the content of the article, please feel free to contact us.",
    "redirect": "/thanks/contact"
  },
  "seminar"    : {
    "id"      : "SEMINAR_FORM_ID",
    "title"   : "Apply for a seminar",
    "desc"    : "After you apply, we will send you a confirmation email.",
    "redirect": "/thanks/seminar"
  },
  "whitepaper" : {
    "id"      : "WHITEPAPER_FORM_ID",
    "title"   : "Download materials for free",
    "desc"    : "You can download it immediately after filling out the form",
    "redirect": "/thanks/whitepaper"
  },
  "case"       : {
    "id"      : "CASE_FORM_ID",
    "title"   : "Request case materials",
    "desc"    : "We will send you detailed information in PDF format.",
    "redirect": "/thanks/case"
  }
} %}

{% set fc = form_config[ns.content_type]|default(form_config["blog"]) %}

{# ③ HTML section #}
<section class="post-form-area post-form-area--{{ ns.content_type }}">
  <h2 class="post-form-area__title">{{ fc.title }}</h2>
  <p  class="post-form-area__desc">{{ fc.desc }}</p>
  <div id="hs-post-form"></div>
</section>{# ④ HubL variable → Bridge to JS → Form generation #}
<script src="//js.hsforms.net/forms/embed/v2.js"></script>
<script>
  var _hsFormConfig = {
    portalId   : "{{ hub_id }}",
    formId     : "{{ fc.id }}",
    redirectUrl: "{{ fc.redirect }}",
    contentType: "{{ ns.content_type }}",
    pageTitle  : "{{ content.name|escape }}",
    pageUrl    : "{{ content.absolute_url }}"
  };

  hbspt.forms.create({
    region     : 'na1',
    portalId   : _hsFormConfig.portalId,
    formId     : _hsFormConfig.formId,
    target     : '#hs-post-form',
    redirectUrl: _hsFormConfig.redirectUrl,

    // Fire GTM event when sending
    onFormSubmitted: function() {
      if (window.dataLayer) {
        window.dataLayer.push({
          event       : 'hs_form_submit',
          contentType : _hsFormConfig.contentType,
          formTitle   : _hsFormConfig.pageTitle,
          formUrl     : _hsFormConfig.pageUrl
        });
      }
    }
  });
</script>

Section 7-4

Complete guide to overriding form CSS

HubSpot forms have default styles that you can use to match your site's design. CSS override is required. However, HubSpot forms do not use inline styles or !important There are many strong styles using To overwriteHigh priority selectormust be used.

CSS application layer (override priority)

4
Site CSS (top priority)
↑ Overwrite here
3
HubSpot theme default styles
theme.json / module.css
2
Default CSS for HubSpot forms
Base styles that HubSpot automatically inserts
1
Inline style (lowest priority) → Must be overwritten with !important
Some elements may be inlined

HubSpot form DOM structure

HTML structure generated by HubSpot forms (excerpt)
<div class="hbspt-form">         <!-- Entire form wrapper -->
  <form class="hs-form">        <!-- form tag body -->

    <div class="hs-form-field">   <!-- Wrapper for each field -->
      <label>お名前</label>
      <div class="hs-input">        <!-- Input element wrapper -->
        <input type="text" class="hs-input">
      </div>
      <ul class="hs-error-msgs">     <!-- Validation error message -->
        <li><label class="hs-error-msg">必須項目です</label></li>
      </ul>
    </div>

    <div class="hs-field-desc">    <!-- Field description -->
    <fieldset class="form-columns-2"> <!-- When using 2-column layout -->

    <div class="hs-submit">        <!-- Send button area -->
      <input type="submit" class="hs-button primary large">
    </div>

    <div class="hs-richtext">      <!-- Rich text description of the form -->
    <div class="submitted-message"> <!-- Thank you message after sending -->

  </form>
</div>

Form CSS override implementation example

css/form-overrides.css — HubSpot form style overrides
/* ===== HubSpot form style override =====
   .post-form-area etc. to increase the priority of the selector
   Limit the scope by enclosing it in the parent class
===================================================== */

/* Reset the entire form */
.post-form-area .hs-form,
.form-section .hs-form {
  max-width: 100%;
  font-family: var(--font-body);
}/* field wrapper */
.post-form-area .hs-form-field {
  margin-bottom: 20px;
}/* label */
.post-form-area .hs-form-field > label {
  display: block;
  font-size: 0.88rem;
  font-weight: 700;
  color: var(--color-text);
  margin-bottom: 6px;
}/* Required mark */
.post-form-area .hs-form-required {
  color: #e53e3e;
  margin-left: 3px;
}/* Text input/select/text area */
.post-form-area .hs-input,
.post-form-area .hs-form input[type="text"],
.post-form-area .hs-form input[type="email"],
.post-form-area .hs-form input[type="tel"],
.post-form-area .hs-form select,
.post-form-area .hs-form textarea {
  width: 100% !important;   /* インラインスタイル上書き */
  padding: 10px 14px;
  border: 1.5px solid #e2e8f0;
  border-radius: var(--border-radius);
  font-size: 0.93rem;
  font-family: var(--font-body);
  background: #fff;
  color: var(--color-text);
  transition: border-color 0.2s;
  appearance: none;
  -webkit-appearance: none;
}

.post-form-area .hs-form input:focus,
.post-form-area .hs-form select:focus,
.post-form-area .hs-form textarea:focus {
  outline: none;
  border-color: var(--color-primary);
  box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb), 0.15);
}/* placeholder */
.post-form-area .hs-form input::placeholder,
.post-form-area .hs-form textarea::placeholder {
  color: #a0aec0;
  font-size: 0.88rem;
}/* text area */
.post-form-area .hs-form textarea {
  min-height: 120px;
  resize: vertical;
}/* Checkbox/radio button */
.post-form-area .hs-form .inputs-list {
  list-style: none;
  padding: 0;
}
.post-form-area .hs-form .inputs-list li {
  padding: 4px 0;
}
.post-form-area .hs-form .inputs-list label {
  display: flex;
  align-items: center;
  gap: 8px;
  font-weight: 400;
  cursor: pointer;
}/* Send button */
.post-form-area .hs-button.primary {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  padding: 14px 28px;
  background: var(--color-primary);
  color: white;
  border: none;
  border-radius: var(--border-radius-btn);
  font-size: 1rem;
  font-weight: 700;
  cursor: pointer;
  transition: background 0.2s, transform 0.1s;
}
.post-form-area .hs-button.primary:hover {
  background: color-mix(in srgb, var(--color-primary) 85%, black);
  transform: translateY(-1px);
}/* Validation error */
.post-form-area .hs-error-msgs {
  list-style: none;
  padding: 0;
  margin-top: 4px;
}
.post-form-area .hs-error-msg {
  color: #e53e3e;
  font-size: 0.8rem;
  font-weight: 600;
}
.post-form-area .hs-form input.invalid.error,
.post-form-area .hs-form select.invalid.error {
  border-color: #e53e3e;
}/* Thank you message after sending */
.post-form-area .submitted-message {
  background: #f0fff4;
  border: 1px solid #9ae6b4;
  border-radius: 8px;
  padding: 24px;
  color: #276749;
  font-weight: 600;
  text-align: center;
}/* Supports 2-column layout */
@media (max-width: 640px) {
  .post-form-area .form-columns-2 .hs-form-field {
    width: 100% !important;
    float: none !important;
  }
}
💡 "Disable default CSS for forms" setting

From the HubSpot admin screen, go to Marketing → Forms → Settings. If you turn on "Disable default CSS for forms", Default styles that HubSpot auto-inserts will no longer load. If you want to create a style using full scratch, turn on this setting. Define all styles in your own CSS. However, this setting affects the entire portal, so be careful not to break the design of existing forms.


Section 7-5

Multi-step forms and pop-up forms

Implementing a popup (modal) form

This is a pattern that displays a form modally when the CTA button is clicked. It is very commonly used as a CTA on landing pages and blog posts.

HubL + JS — Complete implementation of modal forms
{# HTML: Modal trigger button #}
<button
  class="btn btn--primary js-form-modal-trigger"
  data-form-id="{{ fc.id }}"
  data-form-title="{{ fc.title }}"
  aria-haspopup="dialog">
  {{ fc.cta_label|default("Download document") }}
</button>{# HTML: Modal body #}
<div
  id="form-modal"
  class="form-modal"
  role="dialog"
  aria-modal="true"
  aria-labelledby="form-modal-title"
  aria-hidden="true">

  <div class="form-modal__overlay"></div>
  <div class="form-modal__inner">
    <button
      class="form-modal__close"
      aria-label="Close modal">✕</button>
    <h2 id="form-modal-title" class="form-modal__title"></h2>
    <div id="form-modal-body"></div>
  </div>
</div>

<script src="//js.hsforms.net/forms/embed/v2.js"></script>
<script>
(function() {
  var modal       = document.getElementById('form-modal');
  var modalTitle  = document.getElementById('form-modal-title');
  var modalBody   = document.getElementById('form-modal-body');
  var closeBtn    = modal.querySelector('.form-modal__close');
  var overlay     = modal.querySelector('.form-modal__overlay');
  var formLoaded  = false;

  // open modal
  function openModal(formId, formTitle) {
    modalTitle.textContent = formTitle;
    modal.setAttribute('aria-hidden', 'false');
    modal.classList.add('is-open');
    document.body.style.overflow = 'hidden';
    closeBtn.focus();

    // Generate form only the first time (reuse from second time onwards)
    if (!formLoaded) {
      hbspt.forms.create({
        region   : 'na1',
        portalId : '{{ hub_id }}',
        formId   : formId,
        target   : '#form-modal-body',
        onFormSubmitted: function() {
          if (window.dataLayer) {
            window.dataLayer.push({ event: 'hs_form_submit' });
          }
        }
      });
      formLoaded = true;
    }
  }

  // close modal
  function closeModal() {
    modal.setAttribute('aria-hidden', 'true');
    modal.classList.remove('is-open');
    document.body.style.overflow = '';
  }

  // Trigger button event
  document.querySelectorAll('.js-form-modal-trigger').forEach(function(btn) {
    btn.addEventListener('click', function() {
      openModal(
        this.dataset.formId,
        this.dataset.formTitle
      );
    });
  });

  // Close button/overlay click
  closeBtn.addEventListener('click', closeModal);
  overlay.addEventListener('click', closeModal);

  // Close with ESC key
  document.addEventListener('keydown', function(e) {
    if (e.key === 'Escape' && modal.classList.contains('is-open')) {
      closeModal();
    }
  });
})();
</script>

Section 7-6

Thank you page and redirect design

Overall picture of conversion flow

📄
LP/Article
content
📝
form submission
conversion
Thanks page
/thanks/xxx
📧
Email delivery
workflow

Thank you page design for each content type

Content typeThanks page URLPage content
inquiry/thanks/contact“The person in charge will contact you” + Direction to blog
Seminar application/thanks/seminar"Application completed. Please check your confirmation email" + Zoom URL (automatically sent via workflow)
Material DL/thanks/whitepaper"Download here" button + related articles
Request for case study materials/thanks/case"We have sent you an email" + link to other examples

Thank you page implementation pattern

templates/thanks-page.html — Thanks page implementation

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

{% block main_content %}
<div class="thanks-page">

  {# ====== Thanks message (managed by module) ====== #}
  {% module "thanks_message"
    path="../modules/thanks-hero.module"
    label="Thanks Message"
  %}

  {# ====== Only for document DL thanks page: Download button display ====== #}
  {# Determine type by URL path #}
  {% if request.path starts_with "/thanks/whitepaper" %}
    {% module "download_button"
      path="../modules/download-cta.module"
      label="Download CTA"
    %}
  {% endif %}

  {# ====== Next action guidance ====== #}
  <section class="thanks-next">
    <h2>あわせてご覧ください</h2>
    {% set recommend_posts = blog_recent_posts("default", 3) %}
    {% if recommend_posts %}
      <ul class="thanks-next__list">
        {% for post in recommend_posts %}
          <li>
            <a href="{{ post.absolute_url }}">
              {% if post.featured_image %}
                <img src="{{ post.featured_image }}"
                     alt="{{ post.featured_image_alt_text|default(post.name) }}"
                     loading="lazy">
              {% endif %}
              <p>{{ post.name }}</p>
            </a>
          </li>
        {% endfor %}
      </ul>
    {% endif %}
  </section>

</div>{% endblock %}
ℹ️ Thanks page SEO settings

Thank you pages are indexed by search engines They are accessed directly without going through the form, contaminating conversion tracking. surely noindex, nofollow Please set.
Either turn on "Do not allow indexing by search engines" from the page settings on the HubSpot admin screen, or in the head of the template <meta name="robots" content="noindex, nofollow"> Add.


Section 7-7 ★ Updated in 2025

CTA design and implementation patterns

⚠️ The old CTA editor (classic CTA) was retired on November 30, 2025

HubSpot's old CTA editor isAbolished as of November 30, 2025It was done. Existing old CTAs will continue to work and display, butIt is not possible to create a new CTA using the old editor.It has become. For new CTA, go to “Marketing → Content → CTA”New CTA editorPlease use

Also, the old CTA was embedded in the HubL template. {% cta %} tags and hbspt.cta.load() JavaScript embedding will still work, but In new implementationCTA button implementation using custom module + link field (code example below)is recommended.

💡 Main changes in the new CTA editor

The new CTA editor departs from the traditional concept of "CTA object" andContent-block CTAs integrated with smart contentIt has been redesigned as. Rather than embedding directly into the template.Operations arranged and managed by marketers on the page editoris the premise of the UI. As a developer, the current best practice approach is to implement the CTA button/banner module in code and design it so that marketers can replace the content.

Types and usage of CTAs

Inline CTAs

Text/image buttons

Place it in the middle or end of the article in a natural flow. Present offers in context.

banner call to action

section banner

A full-width CTA block placed at the page break. High visibility.

sticky call to action

Fixed display button

A floating button that stays on the screen even when you scroll. Always a call to action.

Pop-up CTAs

Modal slide-in

Displayed as a pop-up after a certain amount of scrolling or when an intention to leave is detected. High attention.

Implementation of CTA banner module

modules/cta-banner.module / module.html
{#
  Define the following in fields.json:
  - bg_color (color)
  - eyecatch (image)
  - label (text): subheading
  - title (text) : Main heading
  - desc (richtext): Explanation text
  - cta_label (text) : Button text
  - cta_link (link) : Button link
  - cta_style (choice) : primary / secondary / ghost
  - open_form_modal (boolean): Open modal form on click?
  - form_id (text): Modal form ID (if open_form_modal=true)
#}

<section
  class="cta-banner"
  style="background-color: {{ module.bg_color.color|default("#0052CC") }};"
  {% if module.eyecatch.src %}
    style="background-image: url('{{ module.eyecatch.src }}'); background-size: cover;"
  {% endif %}>

  <div class="cta-banner__inner">
    {% if module.label %}
      <p class="cta-banner__label">{{ module.label }}</p>
    {% endif %}
    <h2 class="cta-banner__title">{{ module.title }}</h2>
    {% if module.desc %}
      <div class="cta-banner__desc">{{ module.desc }}</div>
    {% endif %}

    {% if module.open_form_modal and module.form_id %}
      {# When opening modally #}
      <button
        class="btn btn--{{ module.cta_style|default("primary") }} js-form-modal-trigger"
        data-form-id="{{ module.form_id }}"
        data-form-title="{{ module.title }}">
        {{ module.cta_label }}
      </button>
    {% elif module.cta_link.url %}
      {# Regular link #}
      <a
        href="{{ module.cta_link.url }}"
        class="btn btn--{{ module.cta_style|default("primary") }}"
        {% if module.cta_link.open_in_new_tab %}
          target="_blank" rel="noopener noreferrer"
        {% endif %}>
        {{ module.cta_label }}
      </a>
    {% endif %}
  </div>
</section>

Implementing a sticky CTA

HubL + JS — Sticky CTAs that appear on scroll
{# Add to the end of body of base.html #}
<div id="sticky-cta" class="sticky-cta" aria-hidden="true">
  {% module "sticky_cta_content"
    path="../modules/sticky-cta.module"
    label="Sticky CTA"
  %}
</div>

<script>
(function() {
  var stickyCta = document.getElementById('sticky-cta');
  if (!stickyCta) return;

  var SHOW_THRESHOLD  = 400;   // How many pixels to scroll before displaying
  var HIDE_NEAR_FORM  = true;  // Hide the form when you get close to it
  var formSection = document.querySelector('.post-form-area');

  window.addEventListener('scroll', function() {
    var scrollY = window.scrollY || window.pageYOffset;

    if (scrollY > SHOW_THRESHOLD) {
      // Hide when form section is near
      if (HIDE_NEAR_FORM && formSection) {
        var formTop = formSection.getBoundingClientRect().top;
        if (formTop < window.innerHeight * 1.2) {
          stickyCta.classList.remove('is-visible');
          stickyCta.setAttribute('aria-hidden', 'true');
          return;
        }
      }
      stickyCta.classList.add('is-visible');
      stickyCta.setAttribute('aria-hidden', 'false');
    } else {
      stickyCta.classList.remove('is-visible');
      stickyCta.setAttribute('aria-hidden', 'true');
    }
  }, { passive: true });
})();
</script>

Section 7-8

Conversion measurement (GA4/GTM collaboration)

By measuring form submissions with GA4/GTM, You can understand which pages, content, and traffic routes are contributing to conversions. We will organize how to connect HubSpot forms and GA4/GTM.

GTM settings and HubSpot form dataLayer events

JS — Form submission measurement using GTM dataLayer
// Submitted in onFormSubmitted callback of hbspt.forms.create
hbspt.forms.create({
  region  : 'na1',
  portalId: '{{ hub_id }}',
  formId  : '{{ fc.id }}',
  target  : '#hs-post-form',

  onFormSubmitted: function($form, data) {

    // ===== Push to GTM dataLayer =====
    if (window.dataLayer) {
      window.dataLayer.push({
        'event'        : 'hs_form_submit',
        'formType'     : '{{ ns.content_type }}',
        'formTitle'    : '{{ fc.title|escape }}',
        'pagePath'     : window.location.pathname,
        'pageTitle'    : document.title
      });
    }

    // ===== Send directly to GA4 (if not using GTM) =====
    if (window.gtag) {
      gtag('event', 'generate_lead', {
        'event_category' : 'form',
        'event_label'    : '{{ fc.title|escape }}',
        'content_type'   : '{{ ns.content_type }}'
      });
    }
  }
});

HubSpot native measurement and GA4 integration

Measuring meansSetting locationWhat you can do
HubSpot Analytics HubSpot admin screen Check the number of form submissions, number of contacts acquired, page views, etc. on the management screen
GA4 (direct collaboration) HubSpot settings → Tracking code → GA4 integration HubSpot automatically inserts GA4 gtag.js. Basic PV measurements are performed automatically
Via GTM HubSpot Settings → Tracking Code → GTM Container ID Insert a GTM container into your HubSpot page. Manage GA4 settings and custom events with GTM
onFormSubmitted callback JS implementation (this section) Send to GA4/GTM with custom parameters such as form type and content type

GTM trigger settings (GTM console settings)

GTM configuration guide — GA4 conversion configuration for hs_form_submit event
//Setting details on the GTM management screen

[Trigger]
  Type: Custom event
  Event name: hs_form_submit
  Occurrence of this trigger: All custom events

[Variable] (Added as a data layer variable)
  formType ← formType of dataLayer
  formTitle ← formTitle of dataLayer
  pagePath ← pagePath of dataLayer

[Tag: GA4 Event]
  Tag type: Google Analytics: GA4 Event
  Measurement ID: G-XXXXXXXXXX
  Event name: generate_lead
  Event parameters:
    form_type → {{formType}}
    form_title → {{formTitle}}
    page_path → {{pagePath}}

[Set as conversion in GA4]
  GA4 Admin → Event → "Mark generate_lead as conversion"
✅ Be careful of double measurement between HubSpot and GA4

HubSpot's "GA4 integration" settings and GTMDouble counting when both are enabledIt will be. If you manage GA4 via GTM, please turn off the GA4 direct link setting in HubSpot. Standardizing on one or the other is the basic principle to maintain measurement accuracy.

Conversion measurement on thank you page

HubL — GA4 conversion submission exclusively for thank you pages
{# Add to Thank You Page Template #}
{% block extra_js %}
<script>
  // Thanks page reached = measured as conversion confirmed
  // Determine type by page URL path
  (function() {
    var path        = window.location.pathname;
    var formTypeMap = {
      '/thanks/seminar'    : 'seminar',
      '/thanks/whitepaper' : 'whitepaper',
      '/thanks/case'       : 'case',
      '/thanks/contact'    : 'contact'
    };

    var formType = 'unknown';
    Object.keys(formTypeMap).forEach(function(key) {
      if (path.startsWith(key)) formType = formTypeMap[key];
    });

    // GTM dataLayer
    if (window.dataLayer) {
      window.dataLayer.push({
        'event'    : 'conversion_pageview',
        'formType' : formType
      });
    }

    // GA4 direct
    if (window.gtag) {
      gtag('event', 'generate_lead', {
        method      : 'thanks_page',
        content_type: formType
      });
    }
  })();
</script>{% endblock %}

Section 7-9

Chapter 7 Summary

📌 Key points to keep in mind in this chapter

How to use two implementation methods

HubL form tags are ideal for fixed placement. hbspt.forms.create is used when dynamic switching of form ID and onFormSubmitted callback are required.

Content type × form linkage

The core of the practice is the 3-step pattern of determining tags → defining form ID, title, and redirect destination using a dictionary → bridging HubL variables to JS.

Form CSS override

Increase the selector priority by limiting the scope in the parent class. Override inline styles with !important. Full control is also possible with the default CSS disable setting.

Thank you page design

Prepare thank you pages with different URLs for each type. Be sure to set noindex. Display the direct download link for the document DL.

Utilizing hidden fields

Automatically set page context information in CRM using form_field_values_json. The quality of handover to sales will improve. It is necessary to add a hidden field to the form side.

Conversion measurement

GTM × onFormSubmitted sends an event with form type and content type. Be careful about double measurement of GA4 and GTM. We also use measurement based on reaching the thank you page.

Next chapter: Chapter 8 Performance/SEO/Accessibility

We will explain Core Web Vitals optimization, lazy loading of images, utilization of HubSpot CDN, structured data, and accessibility implementation.

Go to Chapter 8 →