HubSpot fields.json Deep Dive: Every Field Type, Limit, and Non-Obvious Behavior


fields.json is the backbone of every HubSpot CMS module. It defines what editors see in the sidebar, what data your HubL template receives, and how tightly you control the editing experience. The official docs cover the basics — this article covers everything else: all field types with real examples, the restrictions that will catch you off guard, and the non-obvious behaviors that take months of production experience to discover.

1. The anatomy of a field object

Every field in fields.json is a JSON object placed in the top-level array of the file. At minimum, a field needs three properties: id, name, and type. In practice, you'll almost always include several more.

{
    "id": "headline_text",
    "name": "headline_text",
    "label": "Headline",
    "type": "text",
    "default": "Welcome to our website",
    "required": false,
    "locked": false,
    "help_text": "Main heading displayed at the top of the section.",
    "inline_help_text": "Keep it under 80 characters.",
    "display_width": "half_width"
}

Key universal properties

  • id — Internal identifier, used by HubSpot. Should be unique within the module. Can differ from name, but keeping them identical prevents confusion.
  • name — The variable name you reference in HubL: {{ module.headline_text }}. Must be a valid identifier (no spaces, no hyphens).
  • label — Display name shown to editors. Can be any string.
  • type — The field type. See all types below.
  • default — Starting value before an editor touches the field. The structure depends on field type.
  • required — If true, the editor sees a validation message if the field is empty. Does not prevent page publish on its own.
  • required_to_publish — If true, the page cannot be published until this field has a value. More forceful than required.
  • locked — The field is visible in the editor but grayed out. Editors cannot change it. See the gotchas section for a nuanced explanation.
  • help_text — Tooltip shown when the editor hovers a question-mark icon next to the field label.
  • inline_help_text — Short hint shown directly below the field input.
  • display_width — Set to "half_width" to render two fields side-by-side. Only works on certain simple field types (text, number, boolean, choice). Has no effect on richtext, image, or group fields.

Gotcha: id and name serve different purposes. The id ties the field to its stored data — if you rename it, saved values are lost. The name is what you use in HubL. Changing name breaks your template but does not lose data. Keep both identical and avoid renaming published modules.

2. Text fields: text, textarea, richtext

type: "text"

A single-line input. The most common field type. Supports two useful extra options:

{
    "id": "button_label",
    "name": "button_label",
    "label": "Button label",
    "type": "text",
    "default": "Get started",
    "allow_new_line": false,
    "validation_regex": ""
}
  • allow_new_line — When true, the editor can press Enter to insert a line break. Rendered in HubL as a newline character inside the value. Useful for multi-line headings.
  • validation_regex — A regex string. If the editor's input does not match, a validation error is shown. Example: "^[a-zA-Z0-9 ]+$" to allow only alphanumeric input.

type: "textarea"

A multi-line plain text input. The editor sees a resizable textarea. Output is a plain string — no HTML tags, no formatting. In HubL, use {{ module.field_name | nl2br }} if you want line breaks rendered as <br> tags.

type: "richtext"

A full WYSIWYG editor. Output is an HTML string. Use {{ module.field_name }} directly in your template — do not escape it.

{
    "id": "body_content",
    "name": "body_content",
    "label": "Body content",
    "type": "richtext",
    "default": "<p>Edit this text in the sidebar.</p>",
    "enabled_features": ["bold", "italic", "link", "lists"]
}

enabled_features controls which toolbar buttons appear. If omitted, the full toolbar is shown. Restricting it guides editors toward consistent formatting. Available values include: bold, italic, underline, link, lists, image, table, code_block, blockquote, header, personalization, cta.

Tip: Restrict enabled_features whenever you build a visually opinionated module. Editors inserting arbitrary headings or images inside a richtext field will break layouts you designed carefully. Give them only what the design can absorb.

3. Media fields: image, video, file, icon

type: "image"

Returns an object, not a plain URL. Always reference subfields in your template:

{
    "id": "hero_image",
    "name": "hero_image",
    "label": "Hero image",
    "type": "image",
    "default": {
        "src": "https://cdn2.hubspot.net/placeholder.png",
        "alt": "Hero image",
        "width": 1200,
        "height": 630,
        "loading": "lazy"
    },
    "responsive": true,
    "show_loading": true
}

In your HubL template:

HTML + Hubl
<img
    src="{{ module.hero_image.src }}"
    alt="{{ module.hero_image.alt }}"
    width="{{ module.hero_image.width }}"
    height="{{ module.hero_image.height }}"
    loading="{{ module.hero_image.loading }}"
>
  • responsive — When true, shows controls for editors to configure responsive srcset. HubSpot generates resized variants automatically.
  • show_loading — When true, a loading strategy dropdown (lazy / eager) appears in the editor.

Gotcha: Never output {{ module.hero_image }} directly — it prints the full object as a string. Always use .src, .alt, etc. The same rule applies to all object-type fields.

type: "video"

Renders a video picker tied to HubSpot's video hosting (requires Marketing Hub). Returns an object with player_id, width, height, and thumbnail_url. Use the video_player HubL tag to render it:

Hubl
{% video_player "video"
    overrideable=false,
    value="{{ module.feature_video }}"
%}

type: "file"

A generic file picker from the HubSpot File Manager. Returns a URL string (not an object). Useful for downloadable PDFs, SVG assets, or any non-image file.

type: "icon"

Returns an object with name, type (e.g. "SOLID"), and unicode. Renders via HubSpot's icon set (Font Awesome under the hood).

HTML + Hubl
<i class="fa-{{ module.icon.type | lower }} fa-{{ module.icon.name }}"></i>

4. Choice and boolean fields

type: "choice"

A dropdown, radio group, or checkbox list. One of the most flexible field types for controlling layout variants.

{
    "id": "layout_variant",
    "name": "layout_variant",
    "label": "Layout",
    "type": "choice",
    "display": "radio",
    "default": "left",
    "choices": [
        ["left", "Image left"],
        ["right", "Image right"],
        ["top", "Image top"]
    ],
    "multiple": false
}
  • display"select" (dropdown), "radio", or "checkbox". Use radio for 2–4 options to save editors a click. Use checkbox combined with "multiple": true for multi-select.
  • choices — Array of [value, label] pairs. The value is what your HubL template receives; the label is what the editor sees.
  • multiple — When true, module.field_name is an array. Use {% for item in module.tags %} to iterate.

type: "boolean"

A simple toggle. Returns true or false. Use it for on/off features and pair with visibility to show or hide other fields conditionally.

{
    "id": "show_badge",
    "name": "show_badge",
    "label": "Show badge",
    "type": "boolean",
    "default": false,
    "display": "toggle"
}

display can be "toggle" (modern switch) or "checkbox".

5. Style fields: color, font, spacing, border, backgroundimage

These fields are designed for visual customization. They all return objects — never plain strings.

type: "color"

{
    "id": "accent_color",
    "name": "accent_color",
    "label": "Accent color",
    "type": "color",
    "default": {
        "color": "#0047FF",
        "opacity": 100
    }
}

In HubL, access via {{ module.accent_color.color }} for the hex value and {{ module.accent_color.opacity }} for 0–100 opacity. To use opacity as a CSS value: {{ module.accent_color.opacity / 100 }}.

Common mistake: A frequent bug is writing color: {{ module.accent_color }} in CSS and wondering why it outputs {'color': '#0047FF', 'opacity': 100}. Always use .color and .opacity sub-properties.

type: "font"

Returns an object with family, size, size_unit, color, bold, italic, underline, variant. Includes a Google Fonts picker.

font-family: "{{ module.heading_font.family }}", sans-serif;
font-size: "{{ module.heading_font.size }}{{ module.heading_font.size_unit }}";
color: "{{ module.heading_font.color }}";
font-weight: {% if module.heading_font.bold %}700{% else %}400{% endif %};

type: "spacing"

Padding and margin controls. Returns an object with top, right, bottom, left, each having a value and units.

padding-top: "{{ module.section_padding.top.value }}{{ module.section_padding.top.units }}";

type: "border"

Returns an object per side (top/right/bottom/left), each with width (including value and units), style, and color.

type: "backgroundimage"

An extended image field for CSS backgrounds. Returns src, background_position, background_size, background_attachment.

background-image: url("{{ module.bg.src }}");
background-position: "{{ module.bg.background_position }}";
background-size: "{{ module.bg.background_size }}";

6. Link and navigation fields

type: "link"

The standard field for CTAs and buttons. Returns an object with url (object with href and type) and open_in_new_tab.

{
    "id": "cta_link",
    "name": "cta_link",
    "label": "CTA link",
    "type": "link",
    "default": {
        "url": { "href": "#", "type": "EXTERNAL" },
        "open_in_new_tab": false
    },
    "supported_types": ["EXTERNAL", "CONTENT", "FILE", "EMAIL_ADDRESS"]
}
HTML + Hubl
<a href="{{ module.cta_link.url.href }}"
    {% if module.cta_link.open_in_new_tab %}target="_blank" rel="noopener noreferrer"{% endif %}>
    {{ module.cta_link_label }}
</a>

supported_types controls what kind of URL the editor can enter. Limit it to only what makes sense — a hero button probably shouldn't link to a file download.

type: "page"

A picker scoped to internal HubSpot pages. Returns a URL string. Useful when you want to ensure the link always points to a page in the portal.

type: "menu"

A HubSpot navigation menu selector. Returns a menu ID. Use the {% menu %} HubL tag to render it.

7. HubSpot-specific fields: form, hubdb_table, cta

type: "form"

Lets editors pick any form from the HubSpot Forms tool. Returns an object with form_id, response_type, and message.

{
    "id": "contact_form",
    "name": "contact_form",
    "label": "Form",
    "type": "form",
    "default": {
        "form_id": "",
        "response_type": "redirect",
        "message": "Thank you for submitting."
    }
}
Hubl
{% hubspot_form
    portal_id="{{ portal_id }}"
    form_id="{{ module.contact_form.form_id }}"
%}

type: "hubdb_table" and type: "hubdb_table_row"

hubdb_table returns a table ID. hubdb_table_row returns both a table_id and a row_id. Use these when your module needs to pull structured data from HubDB without hardcoding the table reference.

Tier restriction: HubDB requires CMS Hub Professional or Enterprise (or Content Hub Professional+). Using these field types in a Marketplace module limits your audience significantly — always document this requirement clearly.

type: "cta"

Lets editors pick a HubSpot CTA (Call-to-Action) object from the CTA tool. Returns a CTA ID. Render with {% cta guid=module.hero_cta %}.

8. Groups and repeaters

Groups serve two distinct purposes in fields.json: logical field organization and repeatable items.

Simple group (no occurrence) — organize fields

A group without occurrence is purely cosmetic in the editor — it collapses related fields under a heading. Fields inside it are still accessed as module.group_name.field_name.

{
    "id": "style_group",
    "name": "style_group",
    "label": "Styles",
    "type": "group",
    "children": [
        {
            "id": "bg_color",
            "name": "bg_color",
            "label": "Background color",
            "type": "color",
            "default": { "color": "#ffffff", "opacity": 100 }
        }
    ]
}

In HubL: {{ module.style_group.bg_color.color }}

Repeater group (with occurrence) — dynamic lists

Add the occurrence object to make the group repeatable. Editors can add and remove items. module.group_name becomes an array.

{
    "id": "features",
    "name": "features",
    "label": "Features",
    "type": "group",
    "occurrence": {
        "min": 1,
        "max": 6,
        "default": 3,
        "sorting_label": "Feature"
    },
    "default": [
        { "icon_name": "star", "title": "Feature one" },
        { "icon_name": "bolt", "title": "Feature two" },
        { "icon_name": "heart", "title": "Feature three" }
    ],
    "children": [
        {
            "id": "icon_name",
            "name": "icon_name",
            "label": "Icon",
            "type": "text",
            "default": "star"
        },
        {
            "id": "title",
            "name": "title",
            "label": "Title",
            "type": "text",
            "default": "Feature title"
        }
    ]
}

In HubL:

HTML + Hubl
{% for item in module.features %}
    <div class="feature">
        <span>{{ item.icon_name }}</span>
        <p>{{ item.title }}</p>
    </div>
{% endfor %}
  • occurrence.min — Minimum number of items. Editor cannot remove below this count.
  • occurrence.max — Maximum items. The "Add" button disappears at this limit.
  • occurrence.default — How many items are pre-populated when the module is first added to a page. Does not override default if it already contains items.
  • occurrence.sorting_label — The label shown in the editor sidebar for each item (e.g. "Feature 1", "Feature 2").

Hard limit: You cannot nest a repeater inside a repeater. A group with occurrence cannot have children that are also groups with occurrence. This is a firm HubSpot platform constraint, not a configuration error.

9. Visibility conditions

Visibility conditions show or hide fields based on the value of another field. This keeps the editor sidebar clean and prevents irrelevant fields from confusing editors.

{
    "id": "video_url",
    "name": "video_url",
    "label": "Video URL",
    "type": "text",
    "default": "",
    "visibility": {
        "controlling_field_path": "media_type",
        "operator": "EQUAL",
        "controlling_value_regex": "video"
    }
}

This field only appears when module.media_type equals "video".

  • controlling_field_path — The name of the controlling field. For fields inside a group, use dot notation: "style_group.bg_type".
  • operatorEQUAL, NOT_EQUAL, MATCHES_REGEX, NOT_MATCHES_REGEX, EMPTY, NOT_EMPTY.
  • controlling_value_regex — The value to match. For EQUAL/NOT_EQUAL, this is an exact string comparison.

Tip: Pair a boolean toggle with visibility to create optional sections. Example: an "Enable overlay" toggle that reveals a color picker and opacity slider only when turned on. Editors never see fields they can't use.

10. Non-obvious behaviors and gotchas

1. locked: true is not what you think

When locked is true, the field appears in the editor grayed out — the editor cannot change it. However, the default value in fields.json is still applied. This means you can use locked to set a design-system value that editors can see (for transparency) but cannot override. It is different from hidden: true, which hides the field entirely.

2. Renaming a field loses its saved data

If you change a field's id in a published module, HubSpot can no longer map the saved value to the new ID. Every page using the module will revert that field to its default. This is irreversible for content already published. Treat id as immutable once a module is live.

3. default in a repeater must match the child structure exactly

A repeater's default is an array of objects. Each object's keys must exactly match the name values of the group's children. A mismatch (typo, missing field, extra key) results in the default silently failing — the module adds with empty items instead of the populated defaults.

4. URL prefill does not work inside repeaters

HubSpot allows prefilling simple top-level fields via URL query parameters (?module_field=value). This mechanism does not work for fields nested inside a repeater group. There is no workaround at the fields.json level — it requires custom JavaScript to parse URL parameters and populate the rendered DOM after page load. See our deep dive on this limitation.

5. display_width: "half_width" has silent exclusions

half_width does not work for: richtext, image, backgroundimage, font, spacing, border, gradient, or any group. Setting it on these types is silently ignored — no error, the field just renders full-width anyway. Useful for text, number, boolean, choice, and color.

6. Color field opacity is 0–100, not 0–1

HubSpot stores opacity as an integer from 0 to 100. If you use it in a CSS rgba() or as a CSS opacity property, divide by 100: {{ module.overlay_color.opacity / 100 }}. Forgetting this results in elements that are completely invisible or opaque regardless of the editor's setting.

7. The default value for a link field requires a nested object

A common mistake is setting "default": "#" for a link field. The correct structure is:

"default": {
    "url": { "href": "#", "type": "EXTERNAL" },
    "open_in_new_tab": false
}

Providing a flat string default causes the module to fail rendering with a template error the first time an editor saves the page.

8. required_to_publish blocks the entire page, not just the module

If you mark a field required_to_publish: true and an editor leaves it empty, the Publish button for the entire page is disabled — not just a warning. Use this only for fields that are genuinely critical (e.g. an alt text field when accessibility compliance matters). Using it liberally creates frustrating publishing experiences.

9. Group field defaults do not merge with child field defaults

If you define a default array on a repeater group, it fully replaces the individual default values on child fields. Child defaults only apply when a new item is added by the editor at runtime. For the initial state, the group-level default array is the only source of truth.

10. tab organization requires the tab to exist in tabs

To organize fields into tabs, define a tabs array at the module level (in meta.json, not fields.json) and then assign each field a "tab": "tab_id" property. Assigning a tab that doesn't exist in the module manifest causes the field to appear on the default tab with no error — easy to miss during development.

11. Quick reference table

Type Returns HubL access Notes
text String module.field Supports allow_new_line, validation_regex
textarea String module.field Multi-line, plain text
richtext HTML string module.field Do not escape. Use enabled_features to restrict toolbar
image Object module.field.src, .alt Never output the object directly
video Object video_player tag Requires Marketing Hub
file URL string module.field Any file type from File Manager
icon Object module.field.name, .type Font Awesome based
choice String or Array module.field Array when multiple: true
boolean Boolean module.field Supports display: "toggle"
number Number module.field Integer or float depending on input
color Object module.field.color, .opacity Opacity is 0–100, not 0–1
font Object module.field.family, .size Includes Google Fonts picker
spacing Object module.field.top.value Per-side padding/margin controls
border Object module.field.top.width.value Per-side border controls
backgroundimage Object module.field.src, .background_size CSS background shorthand helper
link Object module.field.url.href Default must be a nested object, not a string
page URL string module.field Scoped to internal pages only
menu Menu ID {% menu %} tag Use HubL menu tag to render
form Object module.field.form_id Use {% hubspot_form %} to render
hubdb_table Table ID module.field Requires CMS Hub Pro+
cta CTA ID {% cta %} tag Marketing Hub CTA tool
group Object or Array module.group.child or {% for %} Array when occurrence is present. No nested repeaters

That covers the full surface area of fields.json. The field types themselves are straightforward once you see them all in one place — the real expertise is in the defaults, the visibility rules, and the behaviors that only surface in production. Next time a module behaves unexpectedly, the answer is usually in one of the gotchas above.

If you're building complex modules with multi-step flows, scoring logic, or repeater-heavy structures, check out the webbro.cc module library for real-world examples of these patterns in action.

Report an issue

Stay in touch

Notes on Web Development

New articles on web development, accessibility, and technical SEO.

Occasional deep dives into platform-specific topics like HubSpot CMS, based on real-world problems and solutions.