📘 HubSpot CMS Development Textbook — 2026 Edition
Chapter 8

Performance/SEO/Accessibility

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.

🎯 Target level:Intermediate to advanced
⏱ Estimated reading completion:60-90 minutes
🔗 Previous chapter:Chapter 7 Form/CTA/Conversion Design

Contents of this chapter

  1. Overview of Core Web Vitals and measurement with HubSpot
  2. LCP optimization: hero images/fonts/preloads
  3. CLS optimization: Image size specification, font swap, advertising space
  4. Complete guide to image optimization (lazy loading, srcset, WebP, HubSpot conversion)
  5. CSS/JavaScript optimization
  6. Leverage HubSpot CDN and get_asset_url
  7. Complete guide to SEO implementation (structured data, canonical, sitemap)
  8. Accessibility (WCAG) implementation guide
  9. Integration with HubSpot SEO tools
  10. Chapter 8 Summary
Section 8-1

Overview of Core Web Vitals and measurement with HubSpot

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.

LCP
Largest Contentful Paint
Maximum content drawing
Good:≤ 2.5s
Needs improvement:≤ 4.0s
Bad:> 4.0s
CLS
Cumulative Layout Shift
cumulative layout shift
Good:≤ 0.1
Needs improvement:≤ 0.25
Bad:> 0.25
INP
Interaction to Next Paint
From operation to next drawing
Good:≤ 200ms
Needs improvement:≤ 500ms
Bad:> 500ms
FCP
First Contentful Paint
first content drawing
Good:≤ 1.8s
Needs improvement:≤ 3.0s
Bad:> 3.0s
TTFB
Time to First Byte
Time to first bite
Good:≤ 800ms
Needs improvement:≤ 1.8s
Bad:> 1.8s

CWV advantages and precautions for HubSpot CMS

itemHubSpot statusIs it necessary to take action?
TTFBLow latency with HubSpot's global CDN. Usually good.Automatically supported
LCPOptimization of hero images depends on developer implementation.Action required
CLSMisalignment is likely to occur due to unspecified image size, font swapping, and module loading.Action required
INPHubSpot's tracking and form scripts can sometimes block the main thread.Be careful
FCPThis can be improved by minimizing render blocking CSS.Action required

measurement tools


Section 8-2

LCP optimization: hero images/fonts/preloads

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.

① Preloading LCP images

Images that become LCP elements like hero banners are before normal image loadingpreloadThis can significantly improve LCP.

layouts/base.html — LCP image preload implementation
{# ====== 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 %}

② LCP image implementation: loading and fetchpriority

module.html — Correct implementation of hero images that become LCP elements
{# ===== 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 %}

③ Font optimization

layouts/base.html — Font loading optimization
{# ===== 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
}
#}

Section 8-3

CLS optimization: Image size specification/font swap

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.

Main causes and countermeasures for CLS

causecountermeasureHubSpot 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
CSS — CLS countermeasures by font swapping
/* ===== 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;
}
CSS — Assuring aspect ratio to image container
/* ===== 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;
}

Section 8-4

Complete guide to image optimization

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.

HubSpot image conversion parameters

parametersexplanationexample
width=NResize width in pixels?width=800
height=NResize height in pixels?height=600
format=webpConvert to WebP format (original format for non-compatible browsers)?format=webp
quality=NCompression quality (1-100) default 80?quality=75
fit=Crop method: cover/contain/fill?fit=cover
upscale=falseDo not enlarge the original image?upscale=false

Implementing responsive images with srcset

HubL — Responsive images using srcset (fully implemented)
{# ===== 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"
  )
}}

Section 8-5

CSS/JavaScript optimization

Eliminate render blocking

layouts/base.html — Optimized loading order for CSS/JS
<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>

HubSpot CDN and get_asset_url

HubL — Correct usage of get_asset_url
{# ===== 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 #}

Section 8-6

Complete guide to SEO implementation

Structured data (JSON-LD) implementation pattern

In addition to the blog article Article schema implemented in Chapter 3, we will summarize commonly used structured data.

HubL — Organization schema (common to all pages)
{# 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>
HubL — BreadcrumbList schema (common to internal pages)
{# 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 %}
HubL — FAQPage schema (FAQ module)
{# 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 %}

Implementing canonical URLs and hreflang

layouts/base.html — canonical and hreflang implementation
{# ===== 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 %}

Meta description quality checklist

📋 Meta description implementation check
Number of characters: 60-120 characters Aim for around 70 characters for PC and around 50 characters for SP. Not too short and not too long.
Fallback settings If meta_description is not set, add processing to automatically generate the text from the beginning.
Escape special characters Do not output | (pipe) or " (double quote) as is.
Handling pagination pages For /blog/2 etc., add text indicating "2nd page".
layouts/base.html — Full implementation of meta description
{% 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 %}

Section 8-7

Accessibility (WCAG) implementation guide

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.

⌨️

keyboard operation

All operations can be completed using only the keyboard. The focus order is logical.

🔊

screen reader

Image alt attributes, ARIA labels, heading structure, and landmark roles are set appropriately.

🎨

Color vision compatible

The text-to-background contrast ratio meets WCAG AA standards (4.5:1 or higher).

📱

touch operation

Tap target must be 44x44px or larger. There is sufficient spacing between adjacent operating elements.

Landmark roll and skip navigation

layouts/base.html — Landmarks/skip navigation
{# ===== 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>

Accessibility of interactive elements

HubL + HTML — accessible navigation modal form
{# ===== 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>

Color contrast ratio guidelines

PurposeWCAG AA standardsWCAG AAA standardsConfirmation tool
Normal text (less than 18px)4.5:1 or higher7:1 or higherAccessibility tab in Chrome DevTools / contrast ratio checker
Large text (18px or more or bold 14px or more)3:1 or more4.5:1 or higher
UI components/graphics3:1 or more

Section 8-8

Integration with HubSpot SEO tools

HubSpot has built-in SEO-specific management tools. By combining it with code implementation, you can develop more effective SEO measures.

Link points between HubSpot SEO tools and implementation

HubSpot's SEO featuresWhat 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

Pre-delivery SEO/performance/accessibility checklist

🚀 Performance
Set fetchpriority="high" and loading="eager" for images in LCP elements Applies to hero banners and eye-catching images. Check LCP elements in PageSpeed ​​Insights.
LCP
Set loading="lazy" on the image below the scroll Applies to all img tags except above-the-fold.
LCP
Explicit width/height attributes for all images If the value is undetermined, set aspect-ratio in CSS instead.
CLS
Reference assets with get_asset_url() Load all CSS, JS, and images via get_asset_url().
TTFB
Add defer attribute to JS files Prevent render blocking. Defer is recommended instead of async.
FCP
🔍 SEO
All pages have canonical URLs Check including pagination pages.
Meta description fallback processing has been implemented Meaningful sentences should be output even on unconfigured pages.
OGP/Twitter Card is implemented in all templates Check operation including image fallback.
Article schema is implemented in blog articles Check with Google Rich Results Test.
noindex is set on the thanks page Check from page settings on the HubSpot admin screen.
♿ Accessibility
All images have appropriate alt attributes Use alt="" for images for decorative purposes.
Skip navigation is implemented Be focused at the beginning when you press the Tab key on your keyboard.
Interactive elements have appropriate ARIA attributes Check the hamburger menu, accordion, and modal.
Color contrast ratio meets WCAG AA standards View all text elements in the Accessibility tab in Chrome DevTools.
All operations can be completed using only the keyboard Ability to operate all interactions using Tab, Enter, Space, and arrow keys.

Section 8-9

Chapter 8 Summary

📌 Key points to keep in mind in this chapter

Core Web Vitals priority support

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.

3-piece image optimization set

HubSpot URL parameter (?width=N&format=webp) + srcset/picture tag + usage of loading attribute (LCP=eager, others=lazy).

get_asset_url() is required

All CSS, JS, and images within the theme are referenced with get_asset_url(). CDN caching and cache busting are automatically enabled.

3 types of structured data

Implemented Organization (all pages) / Article (blog articles) / FAQPage (FAQ module). Add BreadcrumbList, HowTo, etc. as necessary.

Four pillars of accessibility

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.

Pre-delivery checklist

Check performance, SEO, and accessibility comprehensively with PageSpeed ​​Insights, Google Rich Results Test, Chrome DevTools Lighthouse, and Search Console.

Next chapter: Chapter 9 Development environment/CLI/Deployment workflow

We will explain all the HubSpot CLI commands, building a local development environment, linking with GitHub, and best practices for production deployment.

Go to Chapter 9 →