KB — Building — HubSpot CMS development
HubSpot CMS development researched
Standards for building and deploying client themes on HubSpot CMS Hub — covering theme structure, HubL, local development, HubDB, forms, security headers, and the gotchas that burn time.
Use this when
You are building or maintaining a HubSpot CMS site: creating a theme from scratch, adding modules, wiring HubDB content, or deploying via the CLI.
Definition of done
(1) All templates and modules live inside a named theme folder uploaded via the HubSpot CLI.
(2) Every module defines typed fields in meta.json — no magic strings at render time.
(3) HubDB tables are accessed read-only from HubL or the Content API; write operations go through back-end code only.
(4) Forms use HubSpot's native embed or the Forms API — no custom POST to a third-party endpoint without explicit sign-off.
(5) SSL and security headers pass the checks in Security baseline and SSL / domain / DNS cutover.
Requirements
Theme and template structure
Organise every site as a theme — a folder that contains templates, modules, CSS, JS, and a theme.json manifest. HubSpot's Design Manager enforces this hierarchy; flat collections of templates outside a theme cannot be packaged or version-controlled cleanly.
Templates are HubL files (.html with HubL tags). Each template declares a dnd_area or uses {% module %} tags to place modules. Keep templates thin: layout, metadata, and module slots. Put all rendering logic in the module itself.
HubL is a Jinja2 dialect with HubSpot-specific tags and filters. The key tags are {% module %} (place a module), {% widget_block %} (legacy, avoid in new builds), and {{ content.* }} globals for page-level metadata. Use {% set %} and filters (| upper, | truncate) freely — HubL is server-rendered so there is no client performance cost.
Modules and fields
Each module lives in its own folder: modules/my-module.module/ containing meta.json, fields.json, module.html, and optionally module.css / module.js. Define every editor-facing value as a typed field in fields.json — text, image, link, choice, boolean, color. Never rely on hardcoded strings that a content editor cannot change.
Access field values in module.html via {{ module.field_name }}. For repeater fields (groups), iterate with {% for item in module.items %}. Validate required fields with {% if module.field_name %} before rendering — HubSpot renders empty strings for unset fields, which can leave dangling HTML.
CMS content and HubDB
Use HubDB for structured content that editors manage outside the page editor — product tables, team directories, event listings. A HubDB table is a relational table with typed columns; rows are accessible via HubL ({% hubdb_table_rows %}) or the HubDB API (GET /cms/v3/hubdb/tables/{tableIdOrName}/rows). Publish the table before rows appear on live pages — draft rows render only in preview.
The HubDB API enforces a rate limit of 100 requests per 10 seconds per portal (verified against HubSpot API docs, 2025-Q1). Cache responses aggressively on any middleware that sits in front.
Forms and tracking code
Embed HubSpot forms using the native <script> snippet or the Forms Embed API (hbspt.forms.create()). Both methods fire the HubSpot tracking pixel automatically on submission. If you use the Embed API, load //js.hsforms.net/forms/embed/v2.js and call hbspt.forms.create({ portalId, formId }). Avoid custom POST to a HubSpot form action URL — it bypasses AJAX validation and breaks progressive profiling.
Install the HubSpot tracking code (hs-script-loader) on every page, including those outside CMS Hub (marketing pages on other platforms). HubSpot templates inject it automatically; custom templates need the snippet in the <head> or before </body>.
Serverless functions — deprecated as of 2023, verify before use
HubSpot serverless functions are deprecated. HubSpot announced end-of-life for CMS Hub serverless functions; creation of new functions was blocked and remaining functions entered a sunset period. As of 2023, new deployments should not rely on serverless functions — use an external endpoint (Cloudflare Worker, Vercel function, or similar) and call it from the client. Verify current status at developers.hubspot.com/changelog before any engagement that assumed serverless support. (Last checked: 2025-Q4.)
Local development and CLI
Install the HubSpot CLI: npm install -g @hubspot/cli (v6+ as of 2025; verify with hs --version). Authenticate with hs auth — it writes credentials to ~/.hubspot/config.yaml. Add a hubspot.config.yaml at the project root to pin the portal ID and account name for the engagement.
The core workflow is: edit locally → hs upload <src> <dest> to push a file, or hs watch <src> <dest> to push on save. To fetch the current remote state, hs fetch <remote-path> <local-path>. For a full theme upload: hs upload themes/my-theme @hubspot/themes/my-theme. The @hubspot prefix addresses the Design Manager root.
Use hs project upload for projects that use the newer Developer Projects model (introduced for CMS Hub apps and private apps). Traditional theme development still uses plain hs upload.
Domain, SSL, TLS, and security headers
HubSpot manages SSL certificates automatically for domains connected to a portal. TLS 1.2 is the minimum; TLS 1.3 is supported and preferred. HSTS with a one-year max-age is on by default for HubSpot-hosted domains — you do not need to configure it manually, but verify it is present via curl -sI https://yourdomain.com | grep -i strict after DNS cutover (see SSL / domain / DNS cutover).
Custom response headers (CSP, X-Frame-Options, Referrer-Policy) are not configurable through HubSpot's UI for standard CMS Hub hosting. The platform sets its own headers. To add headers, put a Cloudflare proxy in front and inject them at the CDN layer — this is the same pattern described in Security baseline. Confirm the client has a Cloudflare account or budget for it early.
Gotchas
Numeric vs string field IDs. Module field names in HubL are always strings (module.my_field), but HubDB column IDs returned by the REST API are integers. When you mix HubL-rendered data with API-fetched data in a client-side script, the column reference types will differ. Normalise to strings on the JS side.
OAuth access tokens expire in 6 hours. HubSpot OAuth 2.0 access tokens have a 6-hour TTL (source: developers.hubspot.com/docs/api/oauth/tokens). Any integration that stores an access token without a refresh cycle will silently break after the first working day. Use the refresh token flow or switch to a private app token (no expiry, but scoped — preferred for internal integrations).
HubDB draft rows do not appear on live pages. Rows in a HubDB table must be in the published state. A common source of "where is my content?" support calls is that a table was edited without being republished. Remind editors: publish the table after every batch of row edits.
hs watch does not upload deletions. Deleting a local file and letting watch sync will not remove the file from the Design Manager. You must delete remotely via the HubSpot UI or hs remove. This catches teams who clean up local modules and assume the remote follows.
HubL is not available in custom HTML modules that load via AJAX. HubL renders server-side at page-serve time. Any template fetched dynamically after the page loads gets the raw HubL source, not the rendered output. Structure the page so all HubL-dependent content is in the initial server response.
Why & sources
HubSpot's CMS Hub combines a server-side templating engine (HubL), a structured content layer (HubDB), and a CDN-backed hosting platform. Most of the constraints above flow from the platform being opinionated: it controls headers, certificate provisioning, and rendering order in ways that a custom stack does not. Understanding those constraints early prevents late-stage architecture changes.
Primary sources: HubSpot CMS themes docs · HubL reference · HubDB API reference · OAuth token lifecycle · HubSpot developer changelog (serverless deprecation). Last reviewed: 2025-Q4.