The heart of HubSpot CMS development is custom modules. Field type usage frequency derived from 741 module analysis, group/repeat field design, all implementation patterns of module.html, CSS/JS design, and meta.json settings. A complete guide to module development that can be used immediately in the field.
Custom modules in HubSpot CMS1 module = 1 directory (.module)Managed in units of That directory always contains five types of files. This set is the "basic unit of module development."
# Generate module template using CLI (5 files are automatically created) $ hs create module hero-banner --path=./src/my-theme/modules ✔ Created "hero-banner.module" at: src/my-theme/modules/hero-banner.module/ ├── fields.json # Empty array [] is the initial value ├── module.html # empty file ├── module.css # empty file ├── module.js # empty file └── meta.json # Basic information is automatically set
The code base of the actual case analyzed is741 moduleexisted. In a typical configuration for a single site/single theme20-60 modulesis standard. 741 items is a scale in which multiple themes and multiple clients are managed in one repository. This chapter systematizes the patterns obtained from the analysis of all 741 modules.
741 items of all modules fields.json scanned and aggregated the
Actual usage frequency ranking of field typesis.
Please use it to determine the priority of "which field type should be learned first."
text / image / boolean / choice / link The five of This is the field type most frequently used in actual projects. Mastering these five design patterns is a top priority. Group has a complex structure, but it is necessary for all kinds of repetitive content such as FAQs, card grids, tabs, etc. Be sure to master it.
[
{
// ===== Required property =====
"type" : "text", // field type
"name" : "heading", // Key name referenced from HubL (snake_case)
"label" : "Heading", //Label to display in page editor (Japanese recommended)
// ===== Highly recommended properties =====
"default" : "Heading text", // Default value (set so that the display will not be distorted even if it is not set)
"required" : false, // Required input flag
"help_text" : "Maximum 30 characters recommended", // Guide text to display in editor
"placeholder": "Example: Service introduction", // Placeholder for input field (text type only)
// ===== Optional properties =====
"locked" : false, // If set to true, it can be disabled in the editor
"hidden" : false, // Hide in editor if set to true (can be used in HubL)
"tab" : "CONTENT" // "CONTENT" (default) or "STYLE" (style tab)
}
]
| scene | Recommended type | reason |
|---|---|---|
| CTA button (text + link) | link | You can manage everything from opening a separate tab to nofollow in one field. |
| External embed URL only | url | link Lighter. If you don't need text or targeting |
| Main text/explanation (with format) | richtext | When formatting such as bold, list, link etc. is required |
| Heading/Label (no formatting) | text | If you only need plain text, text is lightweight and simple. |
| Show/hide on/off | boolean | Intuitive with toggle UI. Branch at {% if module.xxx %} |
| Switch layout style | choice | Prevent unexpected values from being entered and ensure ease of use of the editor |
"type": "group" and occurrence The combination of
This is the most important and complex design pattern in HubSpot CMS.
Required for all modules that "repeat items" such as FAQs, card grids, tabs, timelines, etc.
[
{
"type" : "text",
"name" : "section_title",
"label" : "Section Heading",
"default": "FAQ"
},
{
// ===== Repeat group (control number with occurrence) =====
"type" : "group",
"name" : "items",
"label" : "FAQ Item",
"occurrence" : {
"min" : 1, // Minimum number of items
"max" : 20, // Maximum number
"default" : 3, // Initial display number
"sorting_label" : "FAQ" // Sort label in editor
},
"children" : [
{
"type" : "text",
"name" : "question",
"label" : "question",
"required": true,
"default": "Please enter your question"
},
{
"type" : "richtext",
"name" : "answer",
"label" : "answer",
"required": true,
"default": "<p>Please enter your answer</p>"
}
]
}
]
[
{
"type": "text",
"name": "section_title",
"label": "Section Heading",
"default": "Service list"
},
{
"type": "choice",
"name": "columns",
"label": "Number of columns",
"choices": [
["2", "2 rows"],
["3", "3 columns (default)"],
["4", "4 rows"]
],
"default": "3",
"display": "radio"
},
{
// ===== Repeating group of cards =====
"type": "group",
"name": "cards",
"label": "card",
"occurrence": { "min": 1, "max": 12, "default": 3 },
"children": [
{
"type" : "image",
"name" : "image",
"label" : "image",
"help_text" : "Recommended size: 640×360px",
"default" : { "src": "", "alt": "", "width": 640, "height": 360 }
},
{
"type" : "text",
"name" : "title",
"label" : "Card title",
"required": true,
"default": "Service name"
},
{
"type" : "richtext",
"name" : "description",
"label" : "Description",
"default": "<p>Please enter a description</p>"
},
{
"type" : "link",
"name" : "cta",
"label" : "Link (optional)",
"default": { "url": { "href": "" }, "open_in_new_tab": false }
}
]
}
]
[
{
"type": "group",
"name": "tabs",
"label": "tab",
"occurrence": { "min": 2, "max": 8, "default": 3 },
"children": [
{
"type" : "text",
"name" : "tab_label",
"label" : "Tab name",
"required": true,
"default": "Tab name"
},
{
"type" : "richtext",
"name" : "tab_content",
"label" : "Tab Content",
"default": "<p>Enter content</p>"
},
{
// nested group (add icon list inside tab)
"type": "group",
"name": "icon_list",
"label": "Icon list (optional)",
"occurrence": { "min": 0, "max": 6, "default": 0 },
"children": [
{ "type": "image", "name": "icon", "label": "icon" },
{ "type": "text", "name": "label", "label": "text" }
]
}
]
}
]
"min": 0 , you can set the number of items to 0 in the editor.
You can create optional sections that can be emptied if not used, like the icon list in the example above.
On the module.html side {% if module.items %} Used in conjunction with existence check.
choice The field isMarketers can “change the appearance without touching code”
It's the strongest system. We will introduce three typical patterns confirmed through analysis of actual projects.
// fields.json { "type" : "choice", "name" : "layout", "label" : "Layout", "choices" : [ ["image-left", "Image: Left / Text: Right (default)"], ["image-right", "Image: Right / Text: Left"] ], "default" : "image-left", "display" : "radio" }// module.html — Switch CSS class with choice value <div class="split-section split-section--{{ module.layout }}"> <div class="split-section__image">...</div> <div class="split-section__text">...</div> </div>/* module.css — Implemented with CSS (no JS) */ .split-section { display: flex; gap: 40px; align-items: center; } .split-section--image-left { flex-direction: row; } .split-section--image-right { flex-direction: row-reverse; } @media (max-width: 768px) { .split-section, .split-section--image-right { flex-direction: column; } }
// fields.json { "type" : "choice", "name" : "color_theme", "label" : "Color Theme", "tab" : "STYLE", "choices" : [ ["white", "White (default)"], ["light", "light gray"], ["primary", "Primary color"], ["dark", "dark"] ], "default" : "white", "display" : "select" }// module.html <section class="cta-section cta-section--{{ module.color_theme }}"> ... </section>/* module.css */ .cta-section--white { background: #ffffff; color: #2d3748; } .cta-section--light { background: #f7fafc; color: #2d3748; } .cta-section--primary { background: var(--color-primary); color: #ffffff; } .cta-section--dark { background: #1a202c; color: #f7fafc; }
{% set layout = module.card_style|default("vertical") %} {% if layout == "vertical" %} {# Vertical card (image → text) #} <div class="card card--vertical"> {% if module.image.src %} <div class="card__thumb"> <img src="{{ module.image.src }}?width=640&format=webp" alt="{{ module.image.alt|escape }}" loading="lazy"> </div> {% endif %} <div class="card__body"> <h3>{{ module.title }}</h3> {{ module.description }} </div> </div>{% elif layout == "horizontal" %} {#Horizontal card (image + text horizontally)#} <div class="card card--horizontal"> {% if module.image.src %} <div class="card__thumb"> <img src="{{ module.image.src }}?width=320&format=webp" alt="{{ module.image.alt|escape }}" loading="lazy"> </div> {% endif %} <div class="card__body"> <h3>{{ module.title }}</h3> {{ module.description }} </div> </div>{% elif layout == "icon" %} {# Icon type card (small icon + text) #} <div class="card card--icon"> {% if module.image.src %} <img class="card__icon" src="{{ module.image.src }}?width=80" alt="" aria-hidden="true" width="40" height="40"> {% endif %} <h3>{{ module.title }}</h3> {{ module.description }} </div>{% endif %}
module.html Well then module.フィールド名 Reference the field value with .
If referenced without checking the existence, an error will occur if there is no value.Because,
It is essential to use appropriate check patterns.
{# ===== Text (text / richtext) ===== If the value is an empty string, it is treated as falsy #} {% if module.heading %} <h2>{{ module.heading }}</h2>{% endif %} {# ===== Image field ===== Check existence of image URL in .src ★ Be sure to check not only module.image but also .src #} {% if module.image.src %} <img src="{{ module.image.src }}" alt="{{ module.image.alt|escape }}">{% endif %} {# ===== Link field ===== Check existence of actual URL with .url.href #} {% if module.cta.url.href %} <a href="{{ module.cta.url.href }}">{{ module.cta.name }}</a>{% endif %} {# ===== Group (repeat) field ===== Check that the array is not empty and then loop #} {% if module.items %} {% for item in module.items %} <div>{{ item.title }}</div> {% endfor %} {% endif %} {# ===== boolean field ===== Use true/false as a condition #} {% if module.show_button %} <button>{{ module.button_label }}</button>{% endif %} {# ===== |default() Filter exists and default value at the same time ===== You can set a fallback value when referencing without checking #} <h2>{{ module.heading|default("Please enter a heading") }}</h2> <a href="{{ module.cta.url.href|default("#") }}"> {{ module.cta.name|default("Learn more") }} </a>
Since images are directly linked to performance,
HubSpot's image conversion function/loading attribute·fetchpriority of
Correct settings are very important. We will introduce complete implementation patterns extracted from actual projects.
{# ===== Pattern 1: Standard image (lazy loading) ===== Use when it is not an LCP element (image other than first view) ===================================================== #} {% if module.image.src %} <img src="{{ module.image.src }}?width=800&format=webp" alt="{{ module.image.alt|default("")|escape }}" width="{{ module.image.width|default(800) }}" height="{{ module.image.height|default(450) }}" loading="lazy" decoding="async">{% endif %} {# ===== Pattern 2: LCP element (hero banner image) ===== Use eager + high for the highest content element in first view ===================================================== #} {% if module.hero_image.src %} <img src="{{ module.hero_image.src }}?width=1440&format=webp" alt="{{ module.hero_image.alt|default("")|escape }}" width="1440" height="640" loading="eager" fetchpriority="high" decoding="sync">{% endif %} {# ===== Pattern 3: Responsive image with picture element ===== Distinguishing different image sizes and aspect ratios on SP/PC ===================================================== #} {% if module.image.src %} <picture> {# WebP (for modern browsers) #} <source type="image/webp" srcset=" {{ module.image.src }}?width=480&format=webp 480w, {{ module.image.src }}?width=800&format=webp 800w, {{ module.image.src }}?width=1200&format=webp 1200w " sizes="(max-width: 480px) 480px, (max-width: 800px) 800px, 1200px"> {#Fallback (JPG)#} <img src="{{ module.image.src }}?width=800" alt="{{ module.image.alt|default("")|escape }}" width="{{ module.image.width|default(800) }}" height="{{ module.image.height|default(450) }}" loading="lazy" decoding="async"> </picture>{% endif %} {# ===== Pattern 4: Pass image URL to CSS background image ===== Heroes etc. combined with gradient overlay ===================================================== #} <div class="hero" {% if module.bg_image.src %} style="--bg-image: url('{{ module.bg_image.src|escape_url }}?width=1440&format=webp')" {% endif %}> ... </div>/* CSS side: .hero { background-image: var(--bg-image); } */
| parameters | Example value | effect |
|---|---|---|
| ?width=N | width=800 | Resize width to Npx (height maintains aspect ratio) |
| ?height=N | height=600 | Resize height to Npx |
| ?format=webp | format=webp | Convert to WebP format (great for reducing file size) |
| ?format=jpg | format=jpg | Convert to JPEG format |
| ?quality=N | quality=80 | Specify the image quality from 0 to 100 (default 80) |
| multiple combinations | width=800&format=webp&quality=85 | Parameters can be concatenated with & |
{# ===== link field subproperties ===== Properties that module.cta (link type field) has: .url.href : Link destination URL .url.type : "EXTERNAL" / "INTERNAL" / "EMAIL" .name : Link text (label) .open_in_new_tab: Whether to open in a separate tab (true/false) .no_follow : Whether to add rel="nofollow" ===================================================== #} {% if module.cta.url.href %} <a href="{{ module.cta.url.href }}" class="btn btn--primary" {# Separate tab/nofollow control ===== Append target only if open_in_new_tab is true Add nofollow only if no_follow is true Always add noopener / noreferrer for security #} {% if module.cta.open_in_new_tab %} target="_blank" {% endif %} rel="noopener noreferrer{% if module.cta.no_follow %} nofollow{% endif %}" {# Tell screen readers it's an external link #} {% if module.cta.open_in_new_tab %} aria-label="{{ module.cta.name|escape }}(Opens in new tab)" {% endif %}> {{ module.cta.name|default("Learn more") }} {# Display external link icon if in separate tab (optional) #} {% if module.cta.open_in_new_tab %} <span aria-hidden="true">↗</span> {% endif %} </a>{% endif %}
target="_blank" If you open a link withrel="noopener noreferrer" of
Must be attachedThis is essential for security.
Without this, there is a risk that the original page may be manipulated from the opened page (Tabnabbing attack).
module.cta.open_in_new_tab Conditionally granted if is true,
even if false rel="noopener noreferrer" Best practice is to leave it on.
{# ===== Basic loop + Use of loop variables ===== #} {% if module.items %} <ul class="card-grid card-grid--{{ module.columns|default("3") }}"> {% for item in module.items %} {# Values that can be controlled by loop variable #} {# loop.index : Sequential number starting from 1 (1, 2, 3...) #} {# loop.index0 : Sequential number starting from 0 (0, 1, 2...) #} {# loop.first : First item (true/false) #} {# loop.last : Last item (true/false) #} {# loop.length : Total number of items #} <li class="card-grid__item {% if loop.first %} is-first{% endif %} {% if loop.last %} is-last{% endif %}" data-index="{{ loop.index0 }}"> {% if item.image.src %} <img src="{{ item.image.src }}?width=640&format=webp" alt="{{ item.image.alt|default(item.title)|escape }}" loading="{% if loop.index <= 2 %}eager{% else %}lazy{% endif %}" width="640" height="360"> {# ↑ The first two are eager (display speed is prioritized), the rest are lazy #} {% endif %} {% if item.title %} <h3>{{ item.title }}</h3> {% endif %} {% if item.description %} <div>{{ item.description }}</div> {% endif %} {% if item.cta.url.href %} <a href="{{ item.cta.url.href }}" class="btn" {% if item.cta.open_in_new_tab %} target="_blank" rel="noopener noreferrer" {% endif %}> {{ item.cta.name|default("Learn more") }} </a> {% endif %} </li> {% endfor %} </ul>{% endif %}
Variables cannot be reassigned inside a HubL loop.
If you want to carry over the state across the loop, namespace use.
This is a pattern often used for tag prefix determination in actual projects.
{# ===== Problem: Variables cannot be rewritten with set inside a loop ===== The code below does not work (HubL specifications) {% for item in module.items %} {% set found = true %} ← After exiting the loop, found remains false {%endfor%} ===================================================== #} {# ===== Solution: Use namespace ===== #} {% set ns = namespace( has_featured = false, // Featured? total_count = 0 // counter ) %} {% for item in module.items %} {% if item.is_featured %} {% set ns.has_featured = true %} // rewritten via namespace {% set ns.total_count = ns.total_count + 1 %} {% endif %} <div class="item{% if item.is_featured %} item--featured{% endif %}"> {{ item.title }} </div>{% endfor %} {# You can refer to the namespace value after the loop #} {% if ns.has_featured %} <p>フィーチャーアイテム:{{ ns.total_count }}件</p>{% endif %}
The naming pattern confirmed by the code base analysis of the actual project isBEM(Block__Element--Modifier)is. Prefixing the module name with the Block name prevents collisions with the global scope.
/* =================================================== card-grid.module/module.css BEM naming convention: .card-grid (Block) as the base point =================================================== */ /* ===== Block ===== */ .card-grid { padding-block: var(--section-padding); }/* ===== Block Element ===== */ .card-grid__title { font-size: clamp(1.5rem, 3vw, 2.25rem); font-weight: 700; text-align: center; margin-bottom: 2rem; } .card-grid__list { display: grid; gap: 24px; list-style: none; /* Flexibly control the number of columns with CSS variables (linked with data-columns attribute of module.html) */ grid-template-columns: repeat(var(--cols, 3), 1fr); }/* ===== Modifier (grid column count variant) ===== */ .card-grid__list--2 { --cols: 2; } .card-grid__list--3 { --cols: 3; } .card-grid__list--4 { --cols: 4; }/* ===== Card (child component) ===== */ .card-grid__item {} .card-grid__card { background: #ffffff; border-radius: var(--border-radius, 8px); overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,0.08); transition: box-shadow 0.2s ease, transform 0.2s ease; height: 100%; display: flex; flex-direction: column; } .card-grid__card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.14); transform: translateY(-2px); } .card-grid__thumb { aspect-ratio: 16/9; overflow: hidden; } .card-grid__thumb img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.3s ease; } .card-grid__card:hover .card-grid__thumb img { transform: scale(1.04); } .card-grid__body { padding: 20px; flex: 1; display: flex; flex-direction: column; } .card-grid__card-title { font-size: 1.05rem; font-weight: 700; margin-bottom: 8px; color: var(--color-text, #2d3748); } .card-grid__card-desc { font-size: 0.9rem; color: #718096; line-height: 1.7; flex: 1; } .card-grid__card-cta { margin-top: 16px; }/* ===== Responsive (Mobile First) ===== */ @media (max-width: 480px) { .card-grid__list--2, .card-grid__list--3, .card-grid__list--4 { --cols: 1; } } @media (min-width: 481px) and (max-width: 768px) { .card-grid__list--3, .card-grid__list--4 { --cols: 2; } } @media (min-width: 769px) and (max-width: 1024px) { .card-grid__list--4 { --cols: 3; } }
In analyzing actual projects, rather than referencing CSS variables (var(--xxx)) We observed many patterns that expand values directly in HubL. For maintainability, we recommend referencing via CSS variables.I will. Define CSS variables in base.html or 01-settings.css.
/* ===== Reference CSS variables (linked with theme.json value) =====
var(--color-primary) is a variable defined in base.html
Just change theme.json and it will be reflected in all modules
=================================================== */
.cta-banner { background: var(--color-primary); }
.cta-banner__title { color: #ffffff; }
.cta-banner__btn {
background: #ffffff;
color: var(--color-primary);
border-radius: var(--border-radius);
}
.cta-banner__btn:hover {
background: var(--color-primary);
color: #ffffff;
outline: 2px solid #ffffff;
}
In the analysis of actual cases,Modules with JS are in the minorityIt was. Use module.js only for dynamic UI like accordions, tabs, and sliders. No JS required for simple content display.
/* faq-accordion.module / module.js =================================================== Implementation policy: - Don't use DOMContentLoaded, load the script with the defer attribute - Identify target element by class name (class, not ID) - Designed to work even if multiple modules exist on the same page - Accessibility support with ARIA attributes (aria-expanded / aria-controls) =================================================== */ (function() { // Get all accordions for this module var accordions = document.querySelectorAll('.faq-accordion__item'); accordions.forEach(function(item) { var btn = item.querySelector('.faq-accordion__question'); var content = item.querySelector('.faq-accordion__answer'); if (!btn || !content) return; // Initial ARIA attribute settings btn.setAttribute('aria-expanded', 'false'); content.setAttribute('aria-hidden', 'true'); content.style.height = '0'; content.style.overflow = 'hidden'; content.style.transition = 'height 0.3s ease'; btn.addEventListener('click', function() { var isExpanded = btn.getAttribute('aria-expanded') === 'true'; if (isExpanded) { // close content.style.height = content.scrollHeight + 'px'; requestAnimationFrame(function() { content.style.height = '0'; }); btn.setAttribute('aria-expanded', 'false'); content.setAttribute('aria-hidden', 'true'); item.classList.remove('is-open'); } else { // open content.style.height = content.scrollHeight + 'px'; btn.setAttribute('aria-expanded', 'true'); content.setAttribute('aria-hidden', 'false'); item.classList.add('is-open'); // Change height: auto after transitionend (resize compatible) content.addEventListener('transitionend', function handler() { content.style.height = 'auto'; content.removeEventListener('transitionend', handler); }); } }); }); })();
/* ===== module.html side (pass data with data attribute) ===== <div class="staff-slider" data-autoplay="{{ module.autoplay|lower }}" data-speed="{{ module.speed|default(3000) }}" data-items='{{ module.items|tojson }}'> </div> ============================================== */ // module.js side (read settings from data attribute) (function() { var sliders = document.querySelectorAll('.staff-slider'); sliders.forEach(function(el) { // Get the setting value from the data attribute var autoplay = el.dataset.autoplay === 'true'; var speed = parseInt(el.dataset.speed, 10) || 3000; var items = JSON.parse(el.dataset.items || '[]'); if (!items.length) return; // Initialize the slider (simple implementation without relying on libraries) var current = 0; var slides = []; items.forEach(function(item, i) { var slide = document.createElement('div'); slide.className = 'staff-slider__slide' + (i === 0 ? ' is-active' : ''); slide.innerHTML = '<img src="' + item.image + '?width=400&format=webp" alt="' + item.name + '">' + '<p class="staff-slider__name">' + item.name + '</p>' + '<p class="staff-slider__title">' + item.title + '</p>'; el.appendChild(slide); slides.push(slide); }); function goTo(index) { slides[current].classList.remove('is-active'); current = (index + slides.length) % slides.length; slides[current].classList.add('is-active'); } if (autoplay) { setInterval(function() { goTo(current + 1); }, speed); } }); })();
meta.json defines information related to displaying and categorizing modules in the page editor.
Proper configuration makes it easier for marketers to find your module.
{
"label": "Card Grid",
// Module name displayed in page editor (Japanese OK)
"icon": "layout",
// Icon name (choose from HubSpot's icon set)
// Example: "layout" / "image" / "form" / "blog" / "text" / "list"
// There are many others. You can see the list in the HubSpot documentation
"categories": ["CONTENT"],
// Page editor category classification
// "CONTENT" / "COMMERCE" / "FORMS" / "SOCIAL"
// Multiple specifications possible: ["CONTENT", "FORMS"]
"content_types": ["SITE_PAGE", "LANDING_PAGE", "BLOG_POST"],
// Limit the page types that this module can use
// Can be used for all page types if omitted
// "SITE_PAGE" / "LANDING_PAGE" / "BLOG_POST" / "BLOG_LISTING"
"is_available_for_new_content": true,
// Set to false to disable adding to new pages
// Used to disable old deprecated modules
"smart_type": "NOT_SMART",
// Whether to use it as smart content (Enterprise)
// "NOT_SMART" / "SMART"
"host_template_types": ["PAGE", "BLOG_POST"]
// Link with template type (similar concept to content_types)
}
When deprecating old modules during renewal,
If you suddenly delete a module used in an existing page, the page will break.
is_available_for_new_content: false While setting "Prohibit new additions",
The method of gradual migration without affecting existing pages is often used in actual projects.
Selected from 741 module analyses, "Just remembering this pattern will greatly improve quality."Five implementation patterns.
in the field default If is not set, immediately after placing the module when creating a new page
Problems such as images not displaying, text remaining empty, and layouts collapsing may occur.
“Looking good even in its default state” is the minimum level of module quality.is.
Especially the image field {"src":"","alt":"","width":640,"height":360} ,
The link field is {"url":{"href":""},"open_in_new_tab":false} Be sure to set
{% if module.image.src %} It is mandatory to check the existence of ({% if module.image %} alone is not enough).
and for all images ?width=N&format=webp will be granted.
Webp conversion alone reduces file size by 30-50% on average.
The LCP element (first view largest image) has loading="eager" fetchpriority="high",
Other than that loading="lazy" decoding="async" use.
This usage has a direct impact on Core Web Vitals.
Rather than implementing layout changes in JavaScript,
choice Embed the field value as part of the CSS class name (class="module--{{ module.layout }}"),
A pattern that controls appearance using only CSS is the simplest and easiest to maintain.
Marketers can change the layout by simply selecting a radio button,
Engineers can add new variants simply by adding CSS rules.
occurrence.min: 0 By setting , you can create a state where "nothing is displayed if there is no item".
On the module.html side {% if module.items %} By combining with
You can enable/disable any section without an engineer.
You can have some leeway by setting the maximum number to about ``twice the maximum value that is likely to be used.''
(12 for 3-row cards, 20 for FAQ).
"label": "bg_color" twist "label": "背景色" It is easier for marketers to get confused.
moreover "help_text" to "推奨サイズ: 1440×640px、JPG形式、2MB以下" like
Having specific instructions helps marketers prepare the right image.
This one line will prevent many maintenance requests for ``images looking strange.''
In particular, prioritize settings for image/richtext/text (limited length) fields.
□ All fields default Is the value set?
□ The image field is {% if module.xxx.src %} Are you checking the existence with
□ The link field is {% if module.xxx.url.href %} Are you checking the existence with
□ For all images ?format=webp Is it attached?
□ To LCP element loading="eager" fetchpriority="high" Is it attached?
□ target="_blank" link to rel="noopener noreferrer" Is it attached?
□ Are the field labels in Japanese?
□ The image field has a recommended size. help_text Is it listed in
□ Does the class name of module.css comply with BEM? (Does the Block name correspond to the module name?)
□ meta.json label Does it have a name that is easy to understand in Japanese?
One module consists of 5 files: fields.json / module.html / module.css / module.js / meta.json. The correct order is to start implementation from fields.json and module.html after generating a template using CLI.
Text / image / boolean / choice / link cover 90% of actual cases. Next, if you master group (repetition), you can create anything from FAQs to card grids.
image → .src / link → .url.href / group → as is / text → as is. Prevent errors and display breakdowns by making it a habit to check for existence according to the type.
Reduce file size with ?width=N&format=webp. LCP elements are eager+high, others lazy+async. How you use it will greatly affect your Core Web Vitals score.
The simplest and easiest to maintain pattern is to embed the value of the choice field as part of the CSS class name and switch the layout and color using only CSS.
Japanese labels, explicit image size in help_text, input restrictions by choice, and meaningful default values. Carefulness during development determines the client's self-propelled rate.