Skip to main content

Overview

The Module Export feature generates a complete, production-ready HubSpot CMS module from your form layout. It creates all necessary files - HTML templates, CSS styles, JavaScript functionality, field definitions, and metadata - packaged in a ZIP file ready to upload to HubSpot Design Manager.
Module generation is handled by main/frontend/src/utils/exportModule.ts and a set of specialized generator utilities in the moduleGenerators/ directory.

Export Process

1

Build Your Layout

Design your multi-step form using the visual editor
2

Click Generate Module

Opens the export dialog with module preview
3

Download ZIP

Downloads a .module.zip file to your computer
4

Upload to HubSpot

Upload the ZIP to HubSpot Design Manager
5

Use in Pages

Add the module to any HubSpot page or template

Generated Files

The export process creates a complete module structure:
{form-name}.module/
├── fields.json      # Field definitions and module configuration
├── module.html      # HubL template for rendering
├── module.css       # Styles for the form
├── module.js        # Multi-step navigation logic
└── meta.json        # Module metadata

File Generation

// From exportModule.ts:39-62
export async function generateModule(schema: FormSchema, layout: LayoutState): Promise<Blob> {
  const zip = new JSZip();

  const moduleName = sanitizeModuleName(schema.name);
  const moduleFolder = zip.folder(`${moduleName}.module`);

  if (!moduleFolder) {
    throw new Error('Failed to create module folder in ZIP');
  }

  // Generate all files
  const fieldsJson = generateFieldsJson(schema, layout);
  const moduleHtml = generateModuleHtml();
  const moduleCss = generateModuleCss();
  const moduleJs = generateModuleJs();
  const metaJson = generateMetaJson(schema);

  // Add files to ZIP
  moduleFolder.file('fields.json', JSON.stringify(fieldsJson, null, 2));
  moduleFolder.file('module.html', moduleHtml);
  moduleFolder.file('module.css', moduleCss);
  moduleFolder.file('module.js', moduleJs);
  moduleFolder.file('meta.json', JSON.stringify(metaJson, null, 2));

  // Generate blob
  return await zip.generateAsync({ type: 'blob', compression: 'DEFLATE' });
}

File Structure Details

1. fields.json

Defines the module’s editable fields and default values:
[
  {
    "id": "sr_module_id",
    "name": "sr_module_id",
    "label": "Module ID",
    "type": "text",
    "display_width": "half_width"
  },
  {
    "id": "class",
    "name": "class",
    "label": "Class",
    "type": "text",
    "display_width": "half_width"
  }
]
Basic module configuration fields (lines 100-121 in fieldsJsonGenerator.ts).

Default Values

Your layout is exported as default values:
// From fieldsJsonGenerator.ts:39-94
const stepsDefault = layout.steps.map((step, stepIndex) => {
  const isLastStep = stepIndex === layout.steps.length - 1;

  return {
    tab_label: `STEP ${stepIndex + 1}`,
    tab_name: step.title || `Step ${stepIndex + 1}`,
    field_group: step.rows.map((row) => ({
      field: row.fields.map((fieldName) => {
        const schemaField = schema.fields.find((f) => f.name === fieldName);
        // ... map field properties
        return {
          field_type: mapFieldType(schemaField.type),
          field_label: schemaField.label,
          form_property: schemaField.name,
          required: schemaField.required || false,
          choices: schemaField.options || [],
          // ... more properties
        };
      }),
      margin: 3,
    })),
    buttons: {
      previous: 'Back',
      next: isLastStep ? 'Submit' : 'Continue',
    },
  };
});
Content editors can modify these defaults in HubSpot’s module editor without changing the code.

2. module.html

HubL template that renders the form:
<!-- From moduleHtmlGenerator.ts:1-200 -->
<div class="multistep-form" id="{{ name }}_{{ id }}">
  <!-- Step tabs/indicators -->
  <div class="multistep-form__tabs" role="tablist">
    {% for step in module.steps %}
    <button 
      id="{{ name }}_multistep-form__tab-{{ loop.index }}" 
      class="multistep-form__tabs-item"
      role="tab">
      <span class="step">{{ step.tab_label }}</span>
      {{ step.tab_name }}
    </button>
    {% endfor %}
  </div>

  <!-- Form panels -->
  <div class="multistep-form__form">
    {% for step in module.steps %}
    <section 
      id="{{ name }}_multistep-form__panel-{{ loop.index }}" 
      class="multistep-form__panel"
      role="tabpanel"{{ " hidden" if not loop.first }}>
      
      {% for field_group in step.field_group %}
      <div class="field-group field-group--cols-{{ field_group.field|length }}">
        {% for field in field_group.field %}
        
        <!-- Field rendering based on type -->
        {% if field.field_type == "text" %}
        <div class="form__field">
          <label for="{{ field.form_property }}">
            {{ field.field_label }}
            {% if field.required %}<span class="field-required">*</span>{% endif %}
          </label>
          <input 
            type="text" 
            id="{{ field.form_property }}" 
            name="{{ field.form_property }}"
            class="field-input"
            {{ " required" if field.required }}>
        </div>
        {% elif field.field_type == "email" %}
        <!-- Email field template -->
        {% elif field.field_type == "dropdown" %}
        <!-- Dropdown field template -->
        {% endif %}
        
        {% endfor %}
      </div>
      {% endfor %}
      
      <!-- Navigation buttons -->
      <div class="form-actions">
        {% if not loop.first %}
        <button type="button" class="btn btn-secondary" data-action="prev">
          {{ step.buttons.previous }}
        </button>
        {% endif %}
        
        {% if loop.last %}
        <button type="submit" class="btn btn-primary">
          {{ step.buttons.next }}
        </button>
        {% else %}
        <button type="button" class="btn btn-primary" data-action="next">
          {{ step.buttons.next }}
        </button>
        {% endif %}
      </div>
    </section>
    {% endfor %}
    
    <!-- Hidden HubSpot form for submission -->
    <div class="multistep-form__hs-form" hidden>
      {% form 
        form_to_use="{{ module.form.form_id }}"
        response_type="{{ module.form.response_type }}"
        message="{{ module.form.message }}"
      %}
    </div>
  </div>
</div>
The template uses HubL (HubSpot’s templating language) which is similar to Jinja2/Django templates.

Row Layout Classes

<!-- From moduleHtmlGenerator.ts:25 -->
<div class="field-group{% if field_group.field|length > 1 %} field-group--grid field-group--cols-{{ field_group.field|length }}{% endif %}">
Dynamic classes based on field count:
  • field-group: Base class
  • field-group--grid: Applied when 2+ fields
  • field-group--cols-2: Two columns
  • field-group--cols-3: Three columns

3. module.css

Production-ready styles for the form. Key features:
.field-group--grid {
  display: grid;
  gap: 1rem;
}

.field-group--cols-2 {
  grid-template-columns: repeat(2, 1fr);
}

.field-group--cols-3 {
  grid-template-columns: repeat(3, 1fr);
}

@media (max-width: 768px) {
  .field-group--grid {
    grid-template-columns: 1fr !important;
  }
}
Automatically stacks on mobile.
.multistep-form__tabs-item {
  position: relative;
  opacity: 0.5;
  transition: opacity 0.3s ease;
}

.multistep-form__tabs-item[aria-selected="true"] {
  opacity: 1;
}

.multistep-form__tabs-item[aria-selected="true"]::after {
  content: '';
  position: absolute;
  bottom: 0;
  height: 3px;
  background: var(--primary-color);
}
.field-input,
.field-select,
.field-textarea {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #cbd5e1;
  border-radius: 0.375rem;
  transition: all 0.2s ease;
}

.field-input:focus {
  outline: none;
  border-color: var(--primary-color);
  box-shadow: 0 0 0 3px rgba(var(--primary-color-rgb), 0.1);
}
.field-input[aria-invalid="true"] {
  border-color: #ef4444;
}

.field-error {
  color: #ef4444;
  font-size: 0.875rem;
  margin-top: 0.25rem;
}

4. module.js

JavaScript for multi-step navigation and validation:
// Multi-step form controller
(function() {
  const form = document.querySelector('.multistep-form');
  if (!form) return;
  
  const tabs = form.querySelectorAll('.multistep-form__tabs-item');
  const panels = form.querySelectorAll('.multistep-form__panel');
  let currentStep = 0;
  
  // Navigation handlers
  form.addEventListener('click', (e) => {
    if (e.target.dataset.action === 'next') {
      e.preventDefault();
      if (validateStep(currentStep)) {
        goToStep(currentStep + 1);
      }
    } else if (e.target.dataset.action === 'prev') {
      e.preventDefault();
      goToStep(currentStep - 1);
    }
  });
  
  // Step navigation
  function goToStep(stepIndex) {
    if (stepIndex < 0 || stepIndex >= panels.length) return;
    
    // Hide current
    panels[currentStep].hidden = true;
    tabs[currentStep].setAttribute('aria-selected', 'false');
    
    // Show new
    currentStep = stepIndex;
    panels[currentStep].hidden = false;
    tabs[currentStep].setAttribute('aria-selected', 'true');
    
    // Scroll to top
    form.scrollIntoView({ behavior: 'smooth', block: 'start' });
  }
  
  // Validation
  function validateStep(stepIndex) {
    const panel = panels[stepIndex];
    const requiredFields = panel.querySelectorAll('[required]');
    let isValid = true;
    
    requiredFields.forEach((field) => {
      if (!field.value.trim()) {
        field.setAttribute('aria-invalid', 'true');
        isValid = false;
      } else {
        field.removeAttribute('aria-invalid');
      }
    });
    
    return isValid;
  }
  
  // Form submission
  form.addEventListener('submit', (e) => {
    if (!validateStep(currentStep)) {
      e.preventDefault();
    }
  });
})();
The JavaScript is vanilla JS with no dependencies, ensuring compatibility with all HubSpot environments.

5. meta.json

Module metadata for HubSpot:
// From exportModule.ts:18-36
function generateMetaJson(schema: FormSchema) {
  const moduleId = crypto.randomUUID();

  return {
    label: schema.name,
    icon: 'form',
    css_assets: [],
    external_dependencies: [],
    extra_body_html: '',
    global: false,
    help_text: '',
    host_template_types: ['PAGE', 'BLOG_POST', 'BLOG_LISTING'],
    js_assets: [],
    module_id: moduleId,
    other_assets: [],
    smart_type: 'NOT_SMART',
    tags: [],
    is_available_for_new_content: true,
  };
}
Key properties:
  • label: Display name in module selector
  • icon: ‘form’ icon in HubSpot UI
  • host_template_types: Where module can be used
  • module_id: Unique identifier (UUID)
  • is_available_for_new_content: Shows in module picker

Module Naming

Module names are sanitized for compatibility:
// From exportModule.ts:10-16
function sanitizeModuleName(name: string): string {
  return name
    .trim()
    .replace(/[^a-zA-Z0-9-_\s]/g, '')  // Remove special chars
    .replace(/\s+/g, '-')               // Replace spaces with hyphens
    .toLowerCase();
}
Examples:
  • “Contact Form” → contact-form.module
  • “Sign Up 2024!” → sign-up-2024.module
  • “Multi-Step Registration” → multi-step-registration.module

Field Type Mapping

HubSpot form fields are mapped to module field types:
// From fieldsJsonGenerator.ts:14-37
function mapFieldType(schemaType: string, hasMultipleOptions: boolean): FieldType {
  const type = schemaType.toLowerCase();

  switch (type) {
    case 'email':
      return 'email';
    case 'phone':
    case 'tel':
      return 'phone_number';
    case 'textarea':
      return 'rich_text';
    case 'select':
    case 'dropdown':
      return 'dropdown';
    case 'checkbox':
      return hasMultipleOptions ? 'checkbox' : 'boolean';
    case 'radio':
      return 'radio';
    case 'text':
    case 'number':
    default:
      return 'text';
  }
}

HubSpot → Module

  • texttext
  • emailemail
  • phone/telphone_number
  • textarearich_text
  • selectdropdown
  • Single checkboxboolean
  • Multiple checkboxcheckbox
  • radioradio

Unsupported Types

Types not yet supported:
  • File upload
  • Date picker
  • Rich text editor
  • Hidden fields (coming soon)

Download Process

The download is triggered client-side:
// From exportModule.ts:75-91
export function downloadModule(blob: Blob, moduleName: string): void {
  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');

  link.href = url;
  link.download = `${sanitizeModuleName(moduleName)}.zip`;
  link.style.display = 'none';

  document.body.appendChild(link);
  link.click();

  // Cleanup
  setTimeout(() => {
    document.body.removeChild(link);
    URL.revokeObjectURL(url);
  }, 100);
}

Uploading to HubSpot

1

Navigate to Design Manager

In HubSpot, go to Marketing → Files and Templates → Design Tools
2

Upload Module

Click File → Upload file and select your .zip file
3

Extract Module

HubSpot automatically extracts the module into your file system
4

Verify Files

Check that all 5 files are present in the .module folder
5

Test Module

Add the module to a test page to verify functionality
Create a dedicated folder like /modules/forms/ in Design Manager to organize your form modules.

Using the Module

Adding to a Page

  1. Edit any HubSpot page or template
  2. Click + to add a module
  3. Search for your module by name
  4. Drag onto the page
  5. Configure the HubSpot form in module settings

Module Configuration

Content editors can customize:
  • Add or remove steps
  • Change step titles and labels
  • Reorder steps
The form_property field must exactly match the HubSpot form field name, or submissions will fail.

Best Practices

  • Test thoroughly in preview panel
  • Verify all required fields are included
  • Check field labels for clarity
  • Ensure logical step progression
  • Use descriptive names (e.g., “Newsletter Signup Form”)
  • Avoid special characters
  • Keep names under 50 characters
  • Include version numbers for iterations
  • Test module on a staging/test page first
  • Verify form submissions reach HubSpot
  • Check responsive behavior on mobile
  • Test all validation rules
  • Download module as backup before changes
  • Keep version history in Design Manager
  • Document major changes in module help_text

Troubleshooting

Problem: HubSpot rejects the ZIP fileSolutions:
  • Ensure .module folder structure is correct
  • Verify all 5 files are present
  • Check that JSON files are valid (use JSONLint)
  • Try re-downloading and uploading
Problem: Can’t find module in module selectorSolutions:
  • Check is_available_for_new_content: true in meta.json
  • Verify host_template_types includes current page type
  • Refresh Design Manager
  • Clear HubSpot cache
Problem: Clicking submit doesn’t workSolutions:
  • Verify HubSpot form is selected in module settings
  • Check that form_property names match exactly
  • Ensure form is published in HubSpot
  • Check browser console for JavaScript errors
Problem: Fields don’t stack properlySolutions:
  • Verify module.css media queries are present
  • Check for conflicting CSS in theme
  • Test with HubSpot’s mobile preview
  • Review CSS grid classes in module.html

Advanced Customization

After upload, developers can customize:

Custom Styling

Edit module.css in Design Manager:
/* Custom brand colors */
:root {
  --primary-color: #your-brand-color;
  --error-color: #your-error-color;
}

/* Custom field styles */
.field-input {
  border-radius: 8px;
  border-width: 2px;
}

/* Custom button styles */
.btn-primary {
  background: linear-gradient(to right, #color1, #color2);
}

Custom Validation

Extend module.js:
// Add custom validation rules
function validateStep(stepIndex) {
  const panel = panels[stepIndex];
  let isValid = true;
  
  // Custom validation
  const phoneField = panel.querySelector('[name="phone"]');
  if (phoneField && !isValidPhone(phoneField.value)) {
    showError(phoneField, 'Invalid phone format');
    isValid = false;
  }
  
  return isValid;
}

Conditional Fields

Add conditional logic in module.html:
<!-- Show field based on previous answer -->
{% if field.conditional_logic %}
<div class="form__field"
     data-conditional="true"
     data-property="{{ field.property_to_show }}"
     data-value="{{ field.validation_text }}">
  <!-- Field content -->
</div>
{% endif %}

Build docs developers (and LLMs) love