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.
Our objective is to ship well-structured, maintainable React modules that:
pageContext.EISDIR build errors.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)
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 };
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;
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>
);
}
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>
);
}
HubSpot React modules have a strict convention for CSS auto-loading. You cannot simply import a CSS file from anywhere.
style/ folder inside your module directory.style/header.css for a "Header" module)./* 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; }
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. |
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 %}
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} />
Before you commit your changes, verify the following:
components/modules/YourModuleName.index.jsx exports { Component, fields, meta }.components/modules/YourModuleName/style/yourmodulename.css.LinkField props are set to default={null}.pageContext.languages.