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.
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.
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.
| Purpose | Recommended implementation method | reason |
|---|---|---|
| Fixed page form section | HubL form tag | Simple and can be written directly in the template |
| Switch forms by content type | JS hbspt.forms.create | You can receive form ID from HubL variable and change it dynamically. |
| Modal popup form | JS hbspt.forms.create | Easy to combine with DOM manipulation |
| I want to do my own processing after sending | JS hbspt.forms.create | onFormSubmit callback can be used |
| landing page | Either is possible | Choose according to your requirements |
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.
{# Simplest implementation #} {% form form_id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" %}
{% 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 #} %}
{# 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>
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.
{# 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 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.
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.
{# 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>
{# ① 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>
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.
<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>
/* ===== 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; } }
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.
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.
{# 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>
| Content type | Thanks page URL | Page 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 |
{% 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 %}
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.
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.
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.
Place it in the middle or end of the article in a natural flow. Present offers in context.
A full-width CTA block placed at the page break. High visibility.
A floating button that stays on the screen even when you scroll. Always a call to action.
Displayed as a pop-up after a certain amount of scrolling or when an intention to leave is detected. High attention.
{# 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>
{# 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>
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.
// 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 }}' }); } } });
| Measuring means | Setting location | What 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 |
//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"
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.
{# 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 %}
HubL form tags are ideal for fixed placement. hbspt.forms.create is used when dynamic switching of form ID and onFormSubmitted callback are required.
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.
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.
Prepare thank you pages with different URLs for each type. Be sure to set noindex. Display the direct download link for the document DL.
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.
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.