HubSpot Development Insights by Studio Nope

How to Build a Custom Module in HubSpot CMS (2026) | Studio Nope

Written by StudioNope | Sep 14, 2026, 7:21:06 PM

Follow HubSpot's module quickstart today and the first upload fails. On the current HubSpot CLI, hs upload stops with this:

$ hs upload feature-list-tutorial studio-nope-tutorial/feature-list-tutorial
✖ ERROR Did you mean `hs cms upload`?

CLI 8 moved every CMS command under hs cms. The quickstart still shows hs create, hs upload, hs watch and hs fetch, and it still lists Node.js 10 as the minimum while the CLI needs Node 20.

This guide builds one module from an empty folder with the commands as they work in CLI 8.14.0. The module is a Feature List: a heading, a repeater of items with optional links, a Layout choice that reveals a Columns field, and an accent colour on the Style tab. We uploaded every file below to a HubSpot portal and put the result through HubSpot's marketplace validator, which is named for Marketplace review but checks any module. Terminal output is copied from those runs with account details masked.

What a custom module is, and when to build one

A module is a folder whose name ends in .module. The files inside it each have one job:

  • fields.json defines every input a marketer sees in the page editor.
  • module.html turns those values into markup with HubL.
  • meta.json sets the label, the icon and where the module may be used.
  • module.css and module.js are optional. HubSpot loads each of them once per page, however many copies of the module that page holds.

Build one when a marketer needs to edit a block the theme's own modules can't express, and the block will be reused across pages.

HubL module or CMS React module

HubSpot now documents a second kind. In HubSpot's words, CMS React modules have fields, can be edited in the content editor and work in drag and drop areas, but their HTML comes from a React component instead of HubL. They live in a developer project and deploy with hs project upload. They can't be edited in Design Manager, and some HubL features aren't supported. The CMS React overview covers the setup.

HubSpot hasn't deprecated HubL. A HubL module has no build step and opens in Design Manager, and it's the format of every module we sell on the HubSpot Marketplace. React earns its setup when your team already works in React and the module needs shared components or a lot of client-side state. For a block a marketer fills in and a theme styles, HubL is the shorter road, and it's what this guide builds.

What you need before you start

  • Node.js 20 or later. The CLI package requires it. On Node 18 the CLI crashes on start with Invalid regular expression flags.
  • A HubSpot account and a personal access key for it. Practise on a test portal: uploads land straight in that account's Design Manager.
npm install -g @hubspot/cli
hs --version
hs account auth

hs account auth takes the personal access key and writes a global config file at ~/.hscli/config.yml. If you have an older hubspot.config.yml, the CLI prompts you to migrate it. The account gets a name (you can set it with --name), and the commands below call it my-account.

If a tutorial told you to run hs upload

Version 8.0.0 moved the CMS commands under hs cms and replaced --portal with --account. The old spellings stop with an error and exit with code 1:

$ hs upload feature-list-tutorial studio-nope-tutorial/feature-list-tutorial
✖ ERROR Did you mean `hs cms upload`?

$ hs cms upload feature-list-tutorial studio-nope-tutorial/feature-list-tutorial --portal=main
✖ ERROR Unknown argument: portal

$ hs create module feature-list
✖ ERROR Did you mean `hs cms app|theme|module|webpack|function|template create`?

The full map, from the 8.0.0 release notes:

hs upload   →  hs cms upload
hs watch    →  hs cms watch
hs fetch    →  hs cms fetch
hs create   →  hs cms module create   (also app, theme, template, function, webpack)
hs lint     →  hs cms lint
hs list     →  hs cms list
hs remove   →  hs cms delete
hs mv       →  hs cms mv
--portal    →  -a / --account

Step 1: Create the module

$ hs cms module create feature-list
✔ What should the module label be? Feature List
✔ Is this a React module? No
✔ What types of content will this module be used in? Any
✔ Is this a global module? No
✔ Make this module available for new content? Yes
…
Your new  has been created in …/feature-list.module

The double space in the last line is the CLI's own; 8.14.0 drops the noun. To skip the prompts, pass the label and the content types as flags:

hs cms module create feature-list --module-label "Feature List" --content-types SITE_PAGE,LANDING_PAGE

Two things to know before you script it. The command needs a configured, valid account even though it only writes local files. And with no terminal attached it prints the first prompt, exits with code 0 and creates nothing.

What the scaffold gives you

You get a fields.json holding one rich text field, a module.html holding a single HTML comment, empty module.css and module.js files, and a meta.json with no icon and no categories. The scaffold writes help_text where HubSpot's reference documents inline_help_text. We replaced all of it. The finished example looks like this, and uploads as one folder:

feature-list-tutorial/
  modules/
    feature-list.module/
      fields.json
      meta.json
      module.css
      module.html
    icons/
      feature-list.svg
  templates/
    feature-list-demo.html

There is no module.js. The module needs no script, so it ships none.

meta.json settings that matter

{
  "label": "Feature List",
  "icon": "../icons/feature-list.svg",
  "content_types": ["SITE_PAGE", "LANDING_PAGE"],
  "global": false,
  "is_available_for_new_content": true,
  "categories": ["body_content"],
  "inline_help_text": "Lists what a product or plan includes. Add items under Features. On the Style tab, switch Layout to Grid to choose columns and set the accent colour."
}

content_types decides which editors offer the module. Leave out LANDING_PAGE and the landing page editor never lists it, so check this line first when a new module seems to be missing. global set to true would make every copy share one set of values. inline_help_text appears as a blue info box at the top of the module in the editor, up to 400 characters. The marketplace validator requires icon, and our first validation run failed without it. HubSpot's marketplace rules also ask for at least one category.

Step 2: Define the fields

fields.json is an array, and each object in it becomes an input in the page editor. Every field needs a unique id, the name you read in HubL, a label the marketer sees, and a type. Keep the structure shallow and predictable; our HubL development best practices explain why that pays off once a site grows.

Text and link fields

{
  "id": "fl_heading",
  "name": "heading",
  "label": "Heading",
  "type": "text",
  "default": "What your team gets"
},
…
{
  "id": "fl_feature_link",
  "name": "link",
  "label": "Link",
  "type": "link",
  "supported_types": ["EXTERNAL", "CONTENT"],
  "default": {
    "url": { "content_id": null, "type": "EXTERNAL", "href": "" },
    "open_in_new_tab": false,
    "no_follow": false
  }
}

Reserved words such as name and label can't be field names. HubSpot rejects the upload with field name cannot be 'name', which is why the item title below is title and the link's text is link_text. A link field keeps its address at item.link.url.href.

A repeater for the feature items

{
  "id": "fl_features",
  "name": "features",
  "label": "Features",
  "type": "group",
  "occurrence": {
    "min": 1,
    "max": 12,
    "sorting_label_field": "features.title",
    "default": 3
  },
  "children": [
    {
      "id": "fl_feature_title",
      "name": "title",
      "label": "Feature title",
      "type": "text",
      "default": "Shared inbox"
    },
    …
  ],
  "default": [
    {
      "title": "Shared inbox",
      "description": "Every customer email lands in one place the whole team can answer from.",
      …
    },
    …
  ]
}

A group with occurrence becomes a list the marketer can add items to and reorder, between min and max. sorting_label_field picks the child shown as each item's name in the editor's list. In HubL the group arrives as an array you loop over. Give it a default array with real copy: HubSpot's marketplace rules forbid Lorem ipsum, and the default is what the marketer sees the moment the module lands on a page.

A choice that shows or hides another field

{
  "id": "fl_styles",
  "name": "styles",
  "label": "Styles",
  "type": "group",
  "tab": "STYLE",
  "children": [
    {
      "id": "fl_layout",
      "name": "layout",
      "label": "Layout",
      "type": "choice",
      "display": "radio",
      "choices": [
        ["list", "List"],
        ["grid", "Grid"]
      ],
      "default": "list"
    },
    {
      "id": "fl_columns",
      "name": "columns",
      "label": "Columns",
      "type": "choice",
      "display": "select",
      "choices": [
        ["2", "2 columns"],
        ["3", "3 columns"],
        ["4", "4 columns"]
      ],
      "default": "3",
      "visibility": {
        "controlling_field_path": "styles.layout",
        "controlling_value_regex": "grid",
        "operator": "EQUAL"
      }
    },
    …
  ]
}

controlling_field_path points at the controlling field by its dot path. Columns stays hidden until Layout matches grid. Visibility is an editor rule: it decides what the marketer sees, and HubL still receives every stored value. More on that in the mistakes below.

A colour on the Style tab

{
  "id": "fl_accent_color",
  "name": "accent_color",
  "label": "Accent colour",
  "type": "color",
  "default": { "color": "#2f6fed", "opacity": 100 },
  "inherited_value": {
    "default_value_path": "theme.primary_color",
    "property_value_paths": {
      "color": "theme.primary_color.color",
      "opacity": "theme.primary_color.opacity"
    }
  }
}

A group with "tab": "STYLE" puts its children on the Style tab. Choice, colour, number, icon and image fields all uploaded there in our tests. The inherited_value block ties the colour to the theme's primary colour, so inside a theme the module starts in the brand colour. Outside a theme nothing is inherited and the default renders. The marketplace validator insists on this block, as the last section shows.

Step 3: Write module.html and module.css

{% set layout = module.styles.layout|default('list') %}
{% set accent = module.styles.accent_color %}

<section class="feature-list feature-list--{{ layout }} feature-list-{{ name }}">
  {% if module.heading %}
    <h2 class="feature-list__heading">{{ module.heading|escape }}</h2>
  {% endif %}

  <ul class="feature-list__items">
    {% for item in module.features %}
      <li class="feature-list__item">
        <h3 class="feature-list__title">{{ item.title|escape }}</h3>

        {% if item.description %}
          <p class="feature-list__text">{{ item.description|escape }}</p>
        {% endif %}

        {% if item.link.url.href %}
          <a class="feature-list__link"
             href="{{ item.link.url.href|escape_url }}"
             {% if item.link.open_in_new_tab %}target="_blank" rel="noopener{% if item.link.no_follow %} nofollow{% endif %}"{% elif item.link.no_follow %}rel="nofollow"{% endif %}>
            {{ item.link_text|default('See how it works')|escape }}
          </a>
        {% endif %}
      </li>
    {% endfor %}
  </ul>
</section>

HubL reads fields as module.heading and module.styles.layout, and {% for item in module.features %} walks the repeater. The link renders only when item.link.url.href holds an address, so an item without one shows no empty link. Every value passes through escape or escape_url on its way to the page. The attribute logic on that link tag is the kind of nested HubL that gets hard to read quickly; the HubL Code Formatter indents it before you upload.

Keep two copies from clashing

Put the module on a page twice and both copies share one module.css. If that file sets the accent colour, both copies get the same one. Each copy needs its own selector, and HubL's name variable provides it: it holds the instance's unique name.

{% require_css %}
<style>
  .feature-list-{{ name }} {
    --feature-list-accent: rgba({{ accent.color|default('#2f6fed')|convert_rgb }}, {{ accent.opacity|default(100) / 100 }});
    --feature-list-columns: {{ module.styles.columns|default('3') }};
  }
</style>
{% end_require_css %}

On the rendered page, the copies in a drag and drop area called main became main-module-1 and main-module-2, and the copy fixed in the template kept its own name. require_css moved each style block into the <head>, straight after the single module.css link:

<section class="feature-list feature-list--list feature-list-main-module-1">
<section class="feature-list feature-list--grid feature-list-main-module-2">
<section class="feature-list feature-list--list feature-list-feature_list_fixed">

<style>
  .feature-list-main-module-2 {
    --feature-list-accent: rgba(15, 157, 88, 1.0);
    --feature-list-columns: 3;
  }
</style>

The shared stylesheet reads those custom properties, with a fallback for each:

.feature-list--grid .feature-list__items {
  grid-template-columns: repeat(var(--feature-list-columns, 3), minmax(0, 1fr));
}
…
.feature-list__title::before {
  content: "";
  flex: none;
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 2px;
  background: var(--feature-list-accent, #2f6fed);
}
…
@media (max-width: 767px) {
  .feature-list--grid .feature-list__items {
    grid-template-columns: minmax(0, 1fr);
  }
}

module.css never changes per copy, so it stays one cached file. HubSpot also documents {% scope_css %} for the same job; we prefer custom properties because the per-copy CSS shrinks to two lines. Note minmax(0, 1fr) in the grid: with a bare 1fr, content that can't wrap pushes a card wider than its column.

To test the scoping we changed each copy's values through HubSpot's page API. The first copy went from list to a two-column grid in orange, the second from grid to list in purple, and the fixed copy turned pink. Nothing bled between them:

Step 4: Upload and watch

$ hs cms upload feature-list-tutorial studio-nope-tutorial/feature-list-tutorial -a my-account
Uploading files from "feature-list-tutorial" to "studio-nope-tutorial/feature-list-tutorial" in the Design Manager of account <account-id>
Uploaded file "…/feature-list-tutorial/modules/feature-list.module/meta.json" to "studio-nope-tutorial/feature-list-tutorial/modules/feature-list.module/meta.json"
…
Uploaded file "…/feature-list-tutorial/modules/icons/feature-list.svg" to "studio-nope-tutorial/feature-list-tutorial/modules/icons/feature-list.svg"
✔ SUCCESS Uploading files to "studio-nope-tutorial/feature-list-tutorial" in the Design Manager is complete

The second path is where the folder lands in Design Manager. After the first upload, hs cms watch keeps it in sync every time you save:

hs cms watch feature-list-tutorial studio-nope-tutorial/feature-list-tutorial -a my-account

HubL has no local renderer, so you check each change in a preview after watch has uploaded it. Watch takes --remove if you want files deleted locally to be deleted in the account too, so use it with care.

Step 5: Put it on a page

A page template places modules inside a drag and drop area. Each dnd_module points at the module by path, and any field can get a starting value as a parameter. A group takes a dictionary:

{% dnd_area "main" label="Main content" %}

  {% dnd_section %}
    {% dnd_module path="../modules/feature-list.module", offset=0, width=12 %}
    {% end_dnd_module %}
  {% end_dnd_section %}

  {% dnd_section %}
    {% dnd_module
      path="../modules/feature-list.module",
      offset=0,
      width=12,
      heading="Also in every plan",
      styles={
        "layout": "grid",
        "columns": "3",
        "accent_color": { "color": "#0f9d58", "opacity": 100 }
      }
    %}
    {% end_dnd_module %}
  {% end_dnd_section %}

{% end_dnd_area %}

{% module "feature_list_fixed" path="../modules/feature-list.module" label="Feature List (fixed in template)" heading="Fixed in the template" %}

The first copy renders the defaults from fields.json. The second starts as a green three-column grid, and the marketer can still change all of it. The {% module %} tag at the end places a copy outside the drag and drop area, at a fixed spot in the template, and its name becomes the instance name. Drag and drop areas don't work in blog post or email templates; there, place modules with this fixed tag.

At phone width the grid copy stacks to a single column, from the media query in module.css:

Mistakes we've made building modules

  • Assuming a failed upload rolls back. When HubSpot rejects a module's fields.json, the other files in the module may already be in Design Manager. The run below exited with code 2 and left meta.json and module.html behind with no fields. Fix the error and upload the whole folder again, or clear the leftovers with hs cms delete.
$ hs cms upload vis-tests studio-nope-tutorial/feature-list-tutorial/vis-tests -a my-account
…
Uploaded file "…/vis-tests/rep-ctrl-path.module/meta.json" to "studio-nope-tutorial/feature-list-tutorial/vis-tests/rep-ctrl-path.module/meta.json"
Uploaded file "…/vis-tests/rep-ctrl-path.module/module.html" to "studio-nope-tutorial/feature-list-tutorial/vis-tests/rep-ctrl-path.module/module.html"
✖ ERROR Uploading file "vis-tests" to "studio-nope-tutorial/feature-list-tutorial/vis-tests" failed
✖ ERROR HubSpotHttpError: The post in account <account-id> was bad. internal error
- for controlling_field 'items', field types like group cannot be used as a controlling_field unless occurrence_options is set
  • Controlling visibility from a repeater. That is the error above. A repeating group can only control another field's visibility when the rule counts its items through occurrence_options, for example a count of 2 with the operator GREATER_THAN_OR_EQUAL. For anything else, control the field from a plain choice or boolean.
  • Assuming a hidden field is empty. With Layout set to List, Columns is hidden, yet a stored value of 4 still rendered as --feature-list-columns: 4;. If a hidden value must have no effect, check the controlling value in HubL as well.
  • Putting a text or file field on the Style tab. Both are rejected at upload: text field 'appearance.caption' is not allowed in the 'STYLE' tab. Captions and downloads belong in the content fields.
  • Writing HubL delimiters in module.css. The file runs through the HubL parser, comments included. A {# opens a HubL comment that swallows the rest of the file, so an ID selector like {#features or a HubL example inside a CSS comment silently empties the stylesheet. Write [id=features] and keep comments free of braces.

Check it before you ship

HubSpot's marketplace validator checks the uploaded copy, so give it the Design Manager path, ending in .module. This was our first run:

$ hs cms module marketplace-validate "studio-nope-tutorial/feature-list-tutorial/modules/feature-list.module" -a my-account
- Validating module "studio-nope-tutorial/feature-list-tutorial/modules/feature-list.module"

Required validation results:
✖ ERROR Missing required property for /studio-nope-tutorial/feature-list-tutorial/modules/feature-list.module/meta.json. The meta.json file is missing the `icon` property.
File: /studio-nope-tutorial/feature-list-tutorial/modules/feature-list.module/meta.json
Line number: 11
✖ ERROR Module studio-nope-tutorial/feature-list-tutorial/modules/feature-list.module with field type(s) color needs to inherit from at least one of the following standard field names. Please add [theme.primary_color,theme.secondary_color].
File: studio-nope-tutorial/feature-list-tutorial/modules/feature-list.module

Both fixes were small. We added an icon to meta.json, and gave the colour field the inherited_value block from Step 2, since every colour field has to inherit from theme.primary_color or theme.secondary_color. The next run passed:

Required validation results:
✔ SUCCESS No errors

Look at that first run again. It printed two errors and still exited with code 0, so a deploy script that trusts the exit code ships a module that fails review. Search the output instead:

hs cms module marketplace-validate "studio-nope-tutorial/feature-list-tutorial/modules/feature-list.module" -a my-account 2>&1 | tee validate.log
if grep -q "✖ ERROR" validate.log; then exit 1; fi

The same bar suits client work that never goes near the Marketplace: descriptive labels with no numbers or underscores, and default copy a marketer would publish. If you would rather hand the whole build over, that is the HubSpot CMS development work we do.

FAQ

Why does hs upload say "Did you mean hs cms upload"?

You are on HubSpot CLI 8 or later, which moved every CMS command under hs cms. Run hs cms upload, and use -a where you used --portal.

Why isn't my custom module showing in the page editor?

Check content_types in meta.json first: a module limited to SITE_PAGE is never offered on a landing page. If the types are right, open the module in Design Manager and look for errors, because a module with errors stays out of the add-module list.

Can I use a custom module in emails?

Yes, with EMAIL in content_types. Email modules ignore module.css and module.js, so their styling has to live in module.html. HubSpot's knowledge base lists Marketing Hub Professional or Enterprise for creating email modules.

What's the difference between a global and a local module?

A local module keeps separate values wherever it's placed. A global module, with "global": true in meta.json, has one set of values, and editing it updates every page that uses it.

Should I build new modules in React?

Choose CMS React modules when your team already builds in React and the module needs shared components or client-side state. For content blocks a marketer edits inside a HubL theme, a HubL module has no build step and stays editable in Design Manager.

Build it yourself, or start from a finished one. The Feature List is a teaching module. If the block you need is a mega menu or a pricing table, our HubSpot modules are built and through marketplace review already.