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.
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 — 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.
text, textarea, richtexttype: "text"A single-line input. The most common field type. Supports two useful extra options:
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.
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.
image, video, file, icontype: "image"Returns an object, not a plain URL. Always reference subfields in your template:
In your HubL template:
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:
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).
type: "choice"A dropdown, radio group, or checkbox list. One of the most flexible field types for controlling layout variants.
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.
display can be "toggle" (modern switch) or "checkbox".
color, font, spacing, border, backgroundimageThese fields are designed for visual customization. They all return objects — never plain strings.
type: "color"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.
type: "spacing"Padding and margin controls. Returns an object with top, right, bottom, left, each having a value and 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.
type: "link"The standard field for CTAs and buttons. Returns an object with url (object with href and type) and open_in_new_tab.
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.
form, hubdb_table, ctatype: "form"Lets editors pick any form from the HubSpot Forms tool. Returns an object with form_id, response_type, and message.
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 %}.
Groups serve two distinct purposes in fields.json: logical field organization and repeatable items.
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.
In HubL: {{ module.style_group.bg_color.color }}
Add the occurrence object to make the group repeatable. Editors can add and remove items. module.group_name becomes an array.
In HubL:
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.
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.
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".operator — EQUAL, 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.
locked: true is not what you thinkWhen 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.
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.
default in a repeater must match the child structure exactlyA 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.
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.
display_width: "half_width" has silent exclusionshalf_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.
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.
default value for a link field requires a nested objectA common mistake is setting "default": "#" for a link field. The correct structure is:
Providing a flat string default causes the module to fail rendering with a template error the first time an editor saves the page.
required_to_publish blocks the entire page, not just the moduleIf 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.
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.
tab organization requires the tab to exist in tabsTo 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.
| 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.