Optimizing Core Web Vitals, lazy loading images and next-generation formats, leveraging HubSpot CDN, structured data, keyboard navigation, and ARIA implementation. We will systematize all the technologies that ensure site quality.
Core Web Vitals(Core Web Vitals) is a web quality metric defined by Google. Since 2021, it has been included as part of Google's search ranking algorithm. It is directly connected to SEO. HubSpot CMS also requires conscious optimization.
| item | HubSpot status | Is it necessary to take action? |
|---|---|---|
| TTFB | Low latency with HubSpot's global CDN. Usually good. | Automatically supported |
| LCP | Optimization of hero images depends on developer implementation. | Action required |
| CLS | Misalignment is likely to occur due to unspecified image size, font swapping, and module loading. | Action required |
| INP | HubSpot's tracking and form scripts can sometimes block the main thread. | Be careful |
| FCP | This can be improved by minimizing render blocking CSS. | Action required |
LCP is an index that is directly linked to the "visibility" of a page. On many sites the LCP element will be an image of the hero banner. "Display images faster"That is the top priority measure.
Images that become LCP elements like hero banners are before normal image loadingpreloadThis can significantly improve LCP.
{# ====== Specify preload in head ====== #} {# Method 1: Preload fixed hero image with template #} {% if module.hero_image.src %} <link rel="preload" as="image" href="{{ module.hero_image.src }}" fetchpriority="high">{% endif %} {# Method 2: Preload the featured image of the blog post (in the head block of blog-post.html) #} {% block extra_head %} {% if content.featured_image %} <link rel="preload" as="image" href="{{ content.featured_image }}" fetchpriority="high"> {% endif %} {% endblock %}
{# ===== Hero Banner Image =====Images that become LCP elements must have the following settings:- loading="eager" (disable lazy loading)- fetchpriority="high" (priority hint to browser)- decoding="sync" (disable asynchronous decoding)- Be sure to specify width / height (also necessary to prevent CLS)===================================================== #} {% if module.hero_image.src %} <img src="{{ module.hero_image.src }}" alt="{{ module.hero_image.alt|default("") }}" width="{{ module.hero_image.width|default(1440) }}" height="{{ module.hero_image.height|default(640) }}" loading="eager" fetchpriority="high" decoding="sync">{% endif %} {# ===== Image visible after scrolling (not LCP element) ===== - Lazy loading with loading="lazy" - fetchpriority does not need to be specified (default is "auto") ===================================================== #} {% for card in module.cards %} {% if card.image.src %} <img src="{{ card.image.src }}" alt="{{ card.image.alt|default("") }}" width="{{ card.image.width|default(400) }}" height="{{ card.image.height|default(300) }}" loading="lazy" decoding="async"> {% endif %} {% endfor %}
{# ===== Optimized loading of Google Fonts ===== Display text even while loading fonts with display=swap (Causes CLS, but improves FCP) Establish domain connectivity ahead of time with preconnect ===================================================== #} {# ① Establish Google connection in advance with preconnect #} <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>{# ② Preload font CSS and then load #} {% set heading_font = theme.settings.typography.heading_font %} {% if heading_font.font_set == "GOOGLE" %} {% set font_family_encoded = heading_font.font|replace(" ", "+") %} <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family={{ font_family_encoded }}:wght@400;700;900&display=swap" media="print" onload="this.media='all'"> {# media="print" initially loads it as print-only, and onload switches it to all → Load fonts asynchronously while avoiding render blocking #} <noscript> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family={{ font_family_encoded }}:wght@400;700;900&display=swap"> </noscript>{% endif %} {# ③ Preloading of body font (CLS countermeasure: font-display: optional recommended) #} {# Set the following in CSS: @font-face { font-family: 'Noto Sans JP'; font-display: optional; ← Zero CLS with fallback font } #}
CLS is a metric that quantifies the phenomenon in which content suddenly shifts during page loading. We will summarize the main causes and countermeasures for CLS deterioration on HubSpot sites.
| cause | countermeasure | HubSpot implementation location |
|---|---|---|
| width/height not specified for image | Specify width and height attributes in all img tags | module.html for all modules |
| Swap web fonts | Adjust with font-display: optional or size-adjust | @font-face in variables.css |
| Inserting advertisements/external widgets | Allocate min-height at the insertion destination in advance | Module CSS/Global CSS |
| Dynamically inserted content | Show skeleton UI to reserve space in advance | JS implementation |
| HubSpot chat widget | Delay chat widget loading timing | HubSpot settings / JS |
/* ===== Font display optimization ===== font-display: optional → Continue to use fallback font if font cannot be loaded within 300ms→ CLS is zero because no swap occurs (however, fonts may not be displayed) size-adjust → Correct the size of system fonts to match web fonts→ Minimize layout shift when swapping===================================================== */ @font-face { font-family: 'Noto Sans JP'; font-style: normal; font-weight: 400; font-display: optional; /* Strongest setting to zero CLS */ src: url('...') format('woff2'); }/* Correct the fallback font size with size-adjust */ @font-face { font-family: 'Noto-Sans-JP-fallback'; src: local('Hiragino Sans'), local('Meiryo'); size-adjust: 95%; /* Correct size difference with web font */ ascent-override: 105%; /* Ascent height correction */ descent-override: 25%; /* Descent depth correction */ } body { font-family: 'Noto Sans JP', 'Noto-Sans-JP-fallback', sans-serif; }
/* ===== Ensure aspect ratio of image container with CSS ===== Reserve space even before images load to prevent CLS ===================================================== */ /* Blog card thumbnail (16:9) */ .blog-card__thumb { aspect-ratio: 16 / 9; overflow: hidden; background: #edf2f7; /* Placeholder color before loading */ } .blog-card__thumb img { width: 100%; height: 100%; object-fit: cover; }/* Avatar image (1:1) */ .author-avatar { aspect-ratio: 1 / 1; overflow: hidden; border-radius: 50%; background: #edf2f7; } .author-avatar img { width: 100%; height: 100%; object-fit: cover; }
Images uploaded to HubSpot's file manager are Resize/format conversion using URL parameterscan. By using this feature, you can deliver images that are optimized for display size.
| parameters | explanation | example |
|---|---|---|
| width=N | Resize width in pixels | ?width=800 |
| height=N | Resize height in pixels | ?height=600 |
| format=webp | Convert to WebP format (original format for non-compatible browsers) | ?format=webp |
| quality=N | Compression quality (1-100) default 80 | ?quality=75 |
| fit= | Crop method: cover/contain/fill | ?fit=cover |
| upscale=false | Do not enlarge the original image | ?upscale=false |
{# ===== HubSpot image conversion parameters + srcset combination ===== Automatically select the best image for the device resolution and viewport ===================================================== #} {% macro responsive_image(src, alt, widths, sizes, is_lcp=false, class="") %} {% if src %} {% set base = src|split("?")|first %} {# Provide WebP and original format in picture tag #} <picture> {# WebP source #} <source type="image/webp" sizes="{{ sizes }}" srcset=" {% for w in widths %} {{ base }}?width={{ w }}&format=webp&quality=80 {{ w }}w{% if not loop.last %},{% endif %} {% endfor %} "> {# Original format (fallback) #} <source sizes="{{ sizes }}" srcset=" {% for w in widths %} {{ base }}?width={{ w }}&quality=80 {{ w }}w{% if not loop.last %},{% endif %} {% endfor %} "> {# img tag (fallback) #} <img src="{{ base }}?width={{ widths|first }}&quality=80" alt="{{ alt }}" loading="{% if is_lcp %}eager{% else %}lazy{% endif %}" fetchpriority="{% if is_lcp %}high{% else %}auto{% endif %}" decoding="{% if is_lcp %}sync{% else %}async{% endif %}" {% if class %}class="{{ class }}"{% endif %}> </picture> {% endif %} {% endmacro %} {# ===== Usage example ===== #} {# Hero Banner (LCP Element) #} {{ responsive_image( module.hero_image.src, module.hero_image.alt, widths = [400, 800, 1200, 1600], sizes = "(max-width: 640px) 100vw, (max-width: 1024px) 100vw, 1200px", is_lcp = true, class = "hero__img" ) }} {# Blog card thumbnail (normal image) #} {{ responsive_image( post.featured_image, post.featured_image_alt_text|default(post.name), widths = [320, 640, 800], sizes = "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw", is_lcp = false, class = "blog-card__img" ) }}
<head> {# ① Write critical CSS inline (minimum CSS required to display above-the-fold) #} <style> /* Minimal style required to display hero header */ :root { --color-primary: {{ theme.settings.color.primary_color.color }}; } body { margin: 0; font-family: 'Noto Sans JP', sans-serif; } .site-header { position: sticky; top: 0; z-index: 100; background: #fff; } </style> {# ② Global CSS: Asynchronous loading using media attributes #} <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> {# ③ HubSpot required tag (cannot be placed before LCP) #} {{ standard_header_includes }} </head> <body> {# ... content ... #} {# ④ Load JS with defer at the end of the body (default recommended) #} <script src="{{ get_asset_url('../js/main.js') }}" defer> </script> {# ⑤ Third-party scripts should be loaded last and lazy if possible #} <script> // Load chat widget after 5 seconds (prevents LCP/INP interference) setTimeout(function() { var s = document.createElement('script'); s.src = '//js.hs-scripts.com/PORTAL_ID.js'; s.async = true; document.head.appendChild(s); }, 5000); </script> {# ⑥ HubSpot required tag (end of body) #} {{ standard_footer_includes }} </body>
{# ===== Leverage CDN cache with get_asset_url ===== Reference assets within a theme using get_asset_url(). HubSpot automatically converts the URL to a CDN-optimized URL, Automatically performs cache busting by adding a file hash to the URL. ===================================================== #} {# CSS #} <link rel="stylesheet" href="{{ get_asset_url('../css/main.css') }}">{# JavaScript #} <script src="{{ get_asset_url('../js/main.js') }}" defer></script>{# Images (SVG icons in themes, etc.) #} <img src="{{ get_asset_url('../images/logo.svg') }}" alt="logo">{# ❌ Writing directly with a relative path is not allowed (distributed from the origin without going through a CDN) #} {# <link rel="stylesheet" href="../css/main.css"> ← NG #} {# ✅ Example URL generated by get_asset_url() #} {# https://hs-XXXXXXXXXX.hubspotusercontent-na1.net/hubfs/PORTAL_ID/ my-theme/css/main.css?t=1234567890 #}
In addition to the blog article Article schema implemented in Chapter 3, we will summarize commonly used structured data.
{# Add inside <head> of layouts/base.html #} <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") }}" }, "sameAs": [ "{{ site_settings.twitter_url }}", "{{ site_settings.linkedin_url }}" ] } </script>
{# Output breadcrumb schema for each template #} {# inside the extra_head block of blog-post.html #} {% block extra_head %} <script type="application/ld+json"> { "@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [ { "@type": "ListItem", "position": 1, "name": "ホーム", "item": "{{ site_settings.website_url }}" }, { "@type": "ListItem", "position": 2, "name": "ブログ", "item": "{{ site_settings.website_url }}/blog" }, { "@type": "ListItem", "position": 3, "name": "{{ content.name|escape }}", "item": "{{ content.absolute_url }}" } ] } </script>{% endblock %}
{# Added to the end of faq.module / module.html #} {% if module.items %} <script type="application/ld+json"> { "@context": "https://schema.org", "@type": "FAQPage", "mainEntity": [ {% for item in module.items %} { "@type": "Question", "name": "{{ item.question|escape }}", "acceptedAnswer": { "@type": "Answer", "text": "{{ item.answer|striptags|escape }}" } }{% if not loop.last %},{% endif %} {% endfor %} ] } </script>{% endif %}
{# ===== canonical URL ===== HubSpot automatically sets content.absolute_url Pagination pages (/blog/2 etc.) are also set correctly. ===================================================== #} <link rel="canonical" href="{{ content.absolute_url }}">{# ===== hreflang (for multilingual sites) ===== If you have configured HubSpot's multilingual group settings, it will be automatically inserted. We recommend checking and completing the information manually. ===================================================== #} {% if content.translated_content %} {# Current page #} <link rel="alternate" hreflang="{{ content.language }}" href="{{ content.absolute_url }}"> {# Other language versions #} {% for lang, trans in content.translated_content.items() %} {% if trans.state == "PUBLISHED" %} <link rel="alternate" hreflang="{{ lang }}" href="{{ trans.absolute_url }}"> {% endif %} {% endfor %} {# x-default: Default if language cannot be detected #} <link rel="alternate" hreflang="x-default" href="{{ content.absolute_url }}">{% endif %}
{% block meta_description %} {% set desc = "" %} {#Priority ①: Meta description set on the page #} {% if content.meta_description %} {% set desc = content.meta_description %} {# Priority ②: For blog articles, automatically generate from the beginning of the body #} {% elif content.post_body %} {% set desc = content.post_body|striptags|truncate(110, end="") %} {# Priority ③: Site-wide default description #} {% else %} {% set desc = site_settings.meta_description|default("Site default description") %} {% endif %} {# Add "Nth page" to the pagination page #} {% if current_page_num and current_page_num > 1 %} {% set desc = desc ~ "(" ~ current_page_num ~ "Page)" %} {% endif %} <meta name="description" content="{{ desc|escape }}">{% endblock %}
Accessibility is the implementation of making a site available to all users. We aim to be WCAG 2.1 AA compliant, and implement screen reader support, keyboard operation, and color vision support.
All operations can be completed using only the keyboard. The focus order is logical.
Image alt attributes, ARIA labels, heading structure, and landmark roles are set appropriately.
The text-to-background contrast ratio meets WCAG AA standards (4.5:1 or higher).
Tap target must be 44x44px or larger. There is sufficient spacing between adjacent operating elements.
{# ===== Skip navigation (for keyboard users) ===== Displayed only when the focus is on the beginning, allowing you to jump to the main content ===================================================== #} <a href="#main-content" class="skip-link"> メインコンテンツへスキップ </a> <style> .skip-link { position: absolute; top: -100%; left: 0; background: var(--color-primary); color: white; padding: 8px 16px; font-weight: 700; z-index: 9999; border-radius: 0 0 8px 0; transition: top 0.2s; } .skip-link:focus { top: 0; /* Show when focused */ } </style>{# ===== Correct implementation of landmark role ===== #} <body> {# Banner: Site header (header element + role="banner") #} <header role="banner"> {# Navigation (identified by nav + aria-label) #} <nav aria-label="Global navigation">...</nav> </header> {# Main content (main + id to target skip link) #} <main id="main-content" role="main" tabindex="-1"> {% block main_content %}{% endblock %} </main> {# Content info: Site footer #} <footer role="contentinfo">...</footer> </body>
{# ===== Accessible hamburger menu ===== #} <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> <nav id="global-nav" aria-label="Global navigation" aria-hidden="true"> <ul role="list"> <li><a href="/">ホーム</a></li> <li><a href="/about">会社概要</a></li> </ul> </nav> <script> var btn = document.querySelector('.hamburger'); var nav = document.getElementById('global-nav'); btn.addEventListener('click', function() { var isOpen = btn.getAttribute('aria-expanded') === 'true'; btn.setAttribute('aria-expanded', String(!isOpen)); btn.setAttribute('aria-label', isOpen ? 'メニューを開く' : 'メニューを閉じる'); nav.setAttribute('aria-hidden', String(isOpen)); nav.classList.toggle('is-open', !isOpen); }); </script>{# ===== Accessible Accordion (FAQ) ===== #} {% for item in module.items %} {% set faq_id = "faq-" ~ loop.index %} <div class="faq-item"> <h3> <button id="{{ faq_id }}-btn" class="faq-item__btn" aria-controls="{{ faq_id }}-panel" aria-expanded="false"> {{ item.question }} <span class="faq-item__icon" aria-hidden="true">▼</span> </button> </h3> <div id="{{ faq_id }}-panel" role="region" aria-labelledby="{{ faq_id }}-btn" hidden> {{ item.answer }} </div> </div>{% endfor %} <script> document.querySelectorAll('.faq-item__btn').forEach(function(btn) { btn.addEventListener('click', function() { var expanded = this.getAttribute('aria-expanded') === 'true'; var panel = document.getElementById(this.getAttribute('aria-controls')); this.setAttribute('aria-expanded', String(!expanded)); panel.hidden = expanded; }); }); </script>{# ===== Form accessibility enhancement (HubSpot form override JS) ===== #} <script>// Complete accessibility attributes after the HubSpot form is added to the DOM document.addEventListener('DOMContentLoaded', function() { var observer = new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { mutation.addedNodes.forEach(function(node) { if (node.nodeType === 1 && node.classList.contains('hs-form')) { // Bind the error message to the input field with aria-describedby node.querySelectorAll('.hs-form-field').forEach(function(field) { var input = field.querySelector('input, select, textarea'); var errors = field.querySelector('.hs-error-msgs'); if (input && errors) { var errorId = 'error-' + input.name; errors.id = errorId; input.setAttribute('aria-describedby', errorId); } }); observer.disconnect(); } }); }); }); observer.observe(document.getElementById('hs-post-form'), { childList: true }); }); </script>
| Purpose | WCAG AA standards | WCAG AAA standards | Confirmation tool |
|---|---|---|---|
| Normal text (less than 18px) | 4.5:1 or higher | 7:1 or higher | Accessibility tab in Chrome DevTools / contrast ratio checker |
| Large text (18px or more or bold 14px or more) | 3:1 or more | 4.5:1 or higher | |
| UI components/graphics | 3:1 or more | — |
HubSpot has built-in SEO-specific management tools. By combining it with code implementation, you can develop more effective SEO measures.
| HubSpot's SEO features | What the developer should do |
|---|---|
| SEO recommendations | Solve the improvement items suggested by the management screen (lack of alt attributes, problems with the heading structure, etc.) by implementing them. |
| topic cluster | Design pillar page and cluster page templates with proper internal link structure |
| XML sitemap | Automatically generated by HubSpot./sitemap.xml Check if it is enabled and manage excluded pages in the "Sitemap" settings on the admin screen. |
| robots.txt | Editable from HubSpot settings. Setting to prohibit crawling of the development environment (sandbox) |
| content strategy tools | Keyword targeting is done by marketers. Templates accurately implement heading structure, alt, and canonical |
TTFB automatically improves with HubSpot's CDN. LCP (hero image preload/eager/high) and CLS (width/height explicit/font optimization) are the main issues for developers.
HubSpot URL parameter (?width=N&format=webp) + srcset/picture tag + usage of loading attribute (LCP=eager, others=lazy).
All CSS, JS, and images within the theme are referenced with get_asset_url(). CDN caching and cache busting are automatically enabled.
Implemented Organization (all pages) / Article (blog articles) / FAQPage (FAQ module). Add BreadcrumbList, HowTo, etc. as necessary.
Skip navigation / Landmark roll / Communicate interaction status with ARIA / Color contrast AA standard (4.5:1). We always conduct a keyboard operation test before delivery.
Check performance, SEO, and accessibility comprehensively with PageSpeed Insights, Google Rich Results Test, Chrome DevTools Lighthouse, and Search Console.