This guide details the architecture for creating high-performance HubSpot React modules. We will cover how to structure your directory for the HubSpot CMS, resolve common Vite build errors, and implement best practices for exposing fields to the content editor.
If you are migrating from legacy templates or building a fresh theme, following this "Module-as-Directory" pattern is essential for passing HubSpot's production build checks.
The Goal
Our objective is to ship well-structured, maintainable React modules that:
- Adhere to the Module-as-Directory architecture required by the HubSpot CLI.
- Expose HubSpot-authorable fields (images, text, menus) via the Design Manager.
- Render correctly inside standard HubL templates.
- Include a fallback menu and a dynamic Language Switcher using
pageContext. - Ship with theme-safe CSS and resolve common
EISDIRbuild errors.
The Module Directory Tree
The HubSpot React runtime (powered by Vite) expects modules to be exported from a specific folder structure. Below is the exact file tree required to prevent build errors like "Failed to load URL".
components/
modules/
Header/
├── index.jsx // Entry point (Exports Component, fields, meta)
├── HeaderComponent.jsx // Main UI Logic
├── HeaderFields.jsx // Editor Fields Configuration
├── HeaderMeta.js // Module metadata (Label, Icon, Boolean options)
├── Menu.jsx // Sub-component for Navigation
├── MenuFields.jsx // Sub-fields for Navigation
├── LanguageSwitcher.jsx // Logic for multi-language
└── style/
└── header.css // Scoped CSS (Must match module directory name)
Key Module Files Explained
1. The Entry Point (index.jsx)
This file is mandatory. It aggregates your exports so HubSpot knows where to find the component logic, the field definition, and the metadata.
// index.jsx
import Component from "./HeaderComponent";
import fields from "./HeaderFields";
import meta from "./HeaderMeta";
export { Component, fields, meta };
2. Field Configuration (HeaderFields.jsx)
This file defines what content editors see in the sidebar. We import standard fields from the @hubspot/cms-components library.
// HeaderFields.jsx
import { ModuleFields, TextField, ImageField } from "@hubspot/cms-components/fields";
import MenuFields from "./MenuFields";
import logo from "../../../assets/sprocket.svg";
const fields = (
<ModuleFields>
<ImageField
name="logo"
label="Logo"
default=
resizable
/>
<TextField
name="headerTitle"
label="Header Title"
default="React Header"
/>
{/* We import the menu fields from a separate file to keep this clean */}
{MenuFields}
{/* Note: We do NOT use a field for languages. That comes from pageContext. */}
</ModuleFields>
);
export default fields;
3. The UI Component (HeaderComponent.jsx)
This is where the React magic happens. Note that we retrieve languages from pageContext, not from the module fields.
// HeaderComponent.jsx
import "./style/header.css";
import Menu from "./Menu";
import LanguageSwitcher from "./LanguageSwitcher";
export default function HeaderComponent({ fieldValues, pageContext }) {
const { logo, headerTitle, navigation } = fieldValues;
// Robust check for languages provided by the CMS runtime
const languages = pageContext?.languages || [];
return (
<header className="header-module">
<div className="header-inner">
<img
src={logo.src}
alt={logo.alt}
width={logo.width}
height={logo.height}
className="header-logo"
/>
<h1 className="header-title">{headerTitle}</h1>
<Menu items={navigation} />
<LanguageSwitcher languages={languages} />
</div>
</header>
);
}
4. Navigation with Fallback (Menu.jsx)
To improve the Developer Experience (DX), we add a fallback. This ensures the header looks good in local development even before a user selects a real menu in the CMS.
// Menu.jsx
const sampleMenu = [
{ label: "Home", url: "/" },
{ label: "About Us", url: "/about" },
{ label: "Services", url: "/services" }
];
export default function Menu({ items }) {
// If the user hasn't selected a menu yet, use the sample
const finalItems = Array.isArray(items) && items.length > 0 ? items : sampleMenu;
return (
<nav className="header-nav">
<ul>
{finalItems.map((item, idx) => (
<li key={idx}><a href={item.url}>{item.label}</a></li>
))}
</ul>
</nav>
);
}
CSS and the HubSpot Style Folder Rule
HubSpot React modules have a strict convention for CSS auto-loading. You cannot simply import a CSS file from anywhere.
- Rule 1: Create a
style/folder inside your module directory. - Rule 2: Name the CSS file exactly the same as your module directory (e.g.,
style/header.cssfor a "Header" module). - Rule 3: Import it at the top of your component file.
/* components/modules/Header/style/header.css */
.header-module { padding: 20px; background: #fff; }
.header-inner { display: flex; align-items:center; justify-content:space-between; }
.header-nav ul { display:flex; gap: 20px; list-style:none; }
Troubleshooting: Common Build Errors
If your local dev server or the hs project upload command fails, check these common issues.
| Error Message | Root Cause | The Fix |
|---|---|---|
| EISDIR: illegal operation on a directory | Your HubL template points to a parent folder (e.g., modules/global) instead of the specific module folder. |
Update the path in your HubL to point explicitly to the module folder: path="../components/modules/Header". |
| Failed to load url … resolved id: header.jsx | Vite is confused because an old file named header.jsx exists next to your new Header/ folder. |
Delete the legacy single-file module so the folder-based module is the only source of truth. |
| Element type is invalid | You are trying to import a field component that doesn't exist (e.g., LanguageSelectorField). |
Remove invalid imports. Use pageContext for languages. Only import documented fields from @hubspot/cms-components/fields. |
| LinkField invalid default | The build system rejects LinkField without a valid default value. |
Explicitly set default={null} on all optional LinkFields. |
How to Call the React Module from HubL
Once your folder structure is correct, you can reference the module in your base.hubl.html or any drag-and-drop area. We use the raw tag to ensure HubSpot doesn't try to execute this example code immediately.
Standard Module Call:
{% module "header" path="../../components/modules/Header" %}
Editable Widget Block:
{% widget_block "header_block" type="react_component" path="../../components/modules/Header" %}
{# Child content can go here #}
{% end_widget_block %}
Critical: Footer & LinkFields
When building a Footer with social media icons, you will use the LinkField. The HubSpot cloud builder is strict about defaults. If a link is optional, you must set the default to null, otherwise the upload will fail.
<LinkField name="facebook" label="Facebook URL" default={null} />
Final Checklist
Before you commit your changes, verify the following:
- Module lives in
components/modules/YourModuleName. index.jsxexports{ Component, fields, meta }.- Styles are located at
components/modules/YourModuleName/style/yourmodulename.css. - No duplicate/legacy files exist in the parent directory.
- All optional
LinkFieldprops are set todefault={null}. - Language switcher logic relies on
pageContext.languages.

