Analytics Architecture on HubSpot CMS
HubSpot CMS Hub uses the HubL templating language (Jinja2-based) and a module system for content rendering. Analytics tracking integrates through three layers:
- Site-wide code injection in Settings adds scripts to the head or footer of every page, including blog posts and landing pages
- Page-level code injection in the page editor's Advanced Options adds scripts to individual pages
- HubL template code in coded templates and modules provides the most control, with access to CRM contact data, page metadata, and content variables
HubSpot has its own tracking code (hs-script-loader) that automatically loads on every page. This tracks page views, form submissions, CTA clicks, and meeting bookings in HubSpot's native analytics. When adding external analytics (GA4, GTM), you need to account for the overlap to avoid double-counting events.
The HubSpot tracking code also sets the hubspotutk cookie for contact identification. This cookie persists the visitor's identity across sessions and ties page views to CRM contacts. External analytics tools track independently and do not share this identity unless you explicitly pass it through the data layer.
HubSpot CMS pages are served through HubSpot's CDN with automatic SSL, caching, and image optimization. Scripts added via the admin interface are included in the cached HTML response.
Installing Tracking Scripts
Site-Wide (Recommended)
Add scripts that should run on every page through the HubSpot admin:
- Navigate to Settings > Website > Pages
- Scroll to Site Header HTML (loads in
<head>) or Site Footer HTML (loads before</body>) - Paste your tracking snippet
For GTM, add the head snippet to Site Header HTML:
<!-- GTM - Site Header HTML -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXX');</script>
And the noscript fallback to Site Footer HTML:
<!-- GTM noscript - Site Footer HTML -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXX"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
Page-Level Scripts
For page-specific tracking (landing page conversion pixels, A/B test scripts):
- Open the page in the page editor
- Click Settings > Advanced Options
- Add code to Additional code snippets in head or footer
Blog-Specific Scripts
Blog templates have their own injection point:
- Navigate to Settings > Website > Blog
- Under Advanced, add scripts to the blog template header or footer
These scripts load only on blog pages, making them useful for content-specific analytics like scroll depth tracking or read time measurement.
Data Layer with HubL Templates
HubL gives you access to page metadata, content properties, and CRM contact data. Build a data layer in your coded template or a global module:
{# base.html or a global module #}
<script>
window.dataLayer = window.dataLayer || [];
dataLayer.push({
'page_type': '{{ content.template_path }}',
'page_title': '{{ page_meta.html_title|escapejson }}',
'content_id': '{{ content.id }}',
'content_group': '{{ content_group.name|escapejson }}',
'language': '{{ content.language.languageTag }}',
'domain': '{{ request.domain }}',
'is_landing_page': {{ content.is_landing_page|lower }},
'publish_date': '{{ content.publish_date|datetimeformat("%Y-%m-%d") }}'
});
</script>
Blog Post Data Layer
Blog posts expose additional metadata:
{# blog-post.html template #}
{% if content.blog_post_author %}
<script>
window.dataLayer = window.dataLayer || [];
dataLayer.push({
'event': 'blog_view',
'blog_title': '{{ content.name|escapejson }}',
'blog_author': '{{ content.blog_post_author.display_name|escapejson }}',
'blog_topic': '{{ content.topic_list|join(", ")|escapejson }}',
'blog_publish_date': '{{ content.publish_date|datetimeformat("%Y-%m-%d") }}',
'word_count': {{ content.post_body|striptags|wordcount }}
});
</script>
{% endif %}
CRM Contact Data in Data Layer
HubSpot's unique advantage is CRM data access in templates. For logged-in contacts or identified visitors:
{% if request.contact %}
<script>
window.dataLayer = window.dataLayer || [];
dataLayer.push({
'user_lifecycle_stage': '{{ request.contact.lifecyclestage }}',
'user_company': '{{ request.contact.company|escapejson }}',
'is_customer': {{ 'true' if request.contact.lifecyclestage == 'customer' else 'false' }},
'hubspot_contact_id': '{{ request.contact.vid }}'
});
</script>
{% endif %}
This enables analytics segmentation by lifecycle stage, company size, or any CRM property without client-side identity resolution.
HubSpot Tracking Code Conflicts
HubSpot's native tracking code (hs-script-loader/YOUR_HUB_ID.js) automatically tracks:
- Page views (every navigation)
- Form submissions (HubSpot forms only)
- CTA clicks
- Meeting bookings
- Email link clicks
When running GA4 alongside HubSpot analytics, both systems independently count page views. This is not duplicate data -- they are separate analytics systems with different attribution models. However, if you are sending HubSpot form events to GA4 via GTM, you may double-count conversions.
To prevent form submission double-counting:
// GTM Custom HTML tag - Listen for HubSpot form submissions
window.addEventListener('message', function(event) {
if (event.data.type === 'hsFormCallback' && event.data.eventName === 'onFormSubmitted') {
window.dataLayer.push({
'event': 'hubspot_form_submit',
'form_id': event.data.id,
'form_name': event.data.data?.submissionValues?.firstname ? 'contact_form' : 'unknown'
});
}
});
Use this GTM event as your form conversion trigger instead of a generic form submit listener, which would fire on both HubSpot and non-HubSpot forms.
Disabling HubSpot Tracking Code
If you want to use only external analytics and disable HubSpot's native tracking:
- Navigate to Settings > Tracking & Analytics > Tracking Code
- Toggle off Automatically add tracking code to your pages
Note that disabling the tracking code breaks HubSpot's contact activity timeline, form analytics, and CTA tracking. Most implementations keep both active.
Smart Content and Analytics
HubSpot's Smart Content displays different content to different visitors based on CRM data, lifecycle stage, country, device, or referral source. This creates analytics challenges:
- The same URL serves different content to different visitors
- A/B test results in HubSpot analytics may not match GA4 data
- Content engagement metrics are split across variants without explicit tracking
Track which Smart Content variant a visitor sees:
{% smart_content "hero_cta" %}
{% smart_rule "customer" %}
<div data-smart-variant="customer">
<p>Welcome back!</p>
</div>
<script>
window.dataLayer = window.dataLayer || [];
dataLayer.push({
'event': 'smart_content_view',
'smart_content_id': 'hero_cta',
'smart_variant': 'customer'
});
</script>
{% end_smart_rule %}
{% smart_rule "lead" %}
<div data-smart-variant="lead">
<p>Start your free trial!</p>
</div>
<script>
window.dataLayer = window.dataLayer || [];
dataLayer.push({
'event': 'smart_content_view',
'smart_content_id': 'hero_cta',
'smart_variant': 'lead'
});
</script>
{% end_smart_rule %}
{% end_smart_content %}
CTA Click Tracking
HubSpot CTAs are tracked natively, but if you need CTA click data in GA4:
// GTM Custom HTML tag - Track HubSpot CTA clicks
document.addEventListener('click', function(event) {
var ctaLink = event.target.closest('.hs-cta-wrapper a, .cta_button');
if (ctaLink) {
window.dataLayer = window.dataLayer || [];
dataLayer.push({
'event': 'cta_click',
'cta_id': ctaLink.closest('.hs-cta-wrapper')?.id || 'unknown',
'cta_text': ctaLink.textContent.trim(),
'cta_url': ctaLink.href
});
}
});
Common Errors
| Error | Cause | Fix |
|---|---|---|
| Duplicate page views in GA4 | Both HubSpot tracking code and GA4 count page views | This is expected -- they are separate systems. Only deduplicate if double-firing GA4 events |
| Form submission counted twice | GTM generic form listener fires alongside HubSpot's native tracking | Use hsFormCallback message event instead of generic form submit trigger |
| HubL variables render as empty | Template variable does not exist for the content type | Check content type (blog vs page) and use {% if %} guards before accessing variables |
| Scripts not loading on landing pages | Landing pages use a different template than site pages | Add scripts to both site-wide header AND landing page template settings |
| Smart Content variant not tracked | No data layer push inside smart_rule blocks | Add variant-specific data layer events inside each smart_rule |
| CRM data unavailable in template | Contact not identified (no hubspotutk cookie) |
Use {% if request.contact %} guard; CRM data only works for identified visitors |
| Blog post word count returns 0 | Using content.post_body instead of rendered body |
Ensure the wordcount filter is applied to content.post_body|striptags |
| GTM blocked by Content Security Policy | Custom domain CSP does not allow GTM domains | Add *.googletagmanager.com and *.google-analytics.com to CSP in HubSpot settings |
| Scripts lost after template change | Code was in template-level injection, not site-wide | Use site-wide header/footer injection for persistent scripts |
| Meeting booking not tracked in GA4 | Meeting embed uses iframe, events do not bubble to parent | Listen for HubSpot meeting callback via window.addEventListener('message') |
Performance Considerations
- HubSpot CDN: All pages are served through HubSpot's CDN with automatic caching. Scripts in the site header are included in the cached response and do not add extra requests
- Tracking code weight: HubSpot's tracking code (
hs-script-loader) is ~30KB gzipped. If you disable it, you lose CRM activity tracking but save a network request - Module loading: HubSpot modules with custom JavaScript add to the page's total script weight. Use the
module_assetHubL function to load module JS only when the module is present on the page - Image optimization: HubSpot automatically optimizes images uploaded to the file manager but does not optimize images linked from external URLs. Use HubL's
resize_image_urlfilter for dynamic resizing - Third-party scripts: Audit all scripts in Settings > Website > Pages header/footer. Each script adds to page load time. Remove unused pixels and consolidate through GTM where possible
- Lazy loading: HubSpot does not natively lazy-load images below the fold. Add
loading="lazy"to image modules or use thelazy_loadattribute in custom modules