Skip to content

How to Export Data from HubSpot CMS Hub: Methods, APIs & Limits

Complete guide to exporting HubSpot CMS Hub data — blog posts, pages, HubDB, files — via UI, CLI, and CMS APIs, with rate limits and portability traps.

Roopi Roopi · · 20 min read
How to Export Data from HubSpot CMS Hub: Methods, APIs & Limits
TALK TO AN ENGINEER

Planning a migration?

Get a free 30-min call with our engineers. We'll review your setup and map out a custom migration plan — no obligation.

Schedule a free call
  • 1,500+ migrations completed
  • Zero downtime guaranteed
  • Transparent, fixed pricing
  • Project success responsibility
  • Post-migration support included

How to Export Data from HubSpot CMS Hub: Methods, APIs & Limits

Info

TL;DR — HubSpot CMS Hub Data Export

HubSpot CMS Hub (renamed Content Hub in April 2024) has no single "export everything" button. The native UI gives you blog CSV metadata (without body content), an HTML bulk export (without structured data), and HubDB table CSVs (without relational integrity). For a full-fidelity extraction — body HTML, layoutSections, SEO metadata, HubDB rows, and file assets — you need the CMS APIs: Blog Posts (/cms/blogs/2026-03/posts), Pages (/cms/v3/pages/site-pages and /landing-pages), HubDB, and the Files API. Developer assets (themes, templates, custom modules) require the CLI or Source Code API. API rate limits range from 100 requests/10s (Free/Starter) to 190 requests/10s (Pro/Enterprise), with daily caps of 250K–1M calls. The biggest portability constraint is HubL — HubSpot's proprietary markup language — which is embedded in API responses and must be parsed before import into any other CMS.

Last verified against HubSpot API documentation as of August 2026. HubSpot is actively rolling out date-versioned API paths (e.g., /cms/blogs/2026-03/). Check the HubSpot API changelog for updates.

Exporting data from HubSpot CMS Hub is fundamentally different from exporting CRM records. A CRM export deals with flat, structured data — Contacts, Deals, Tickets. A CMS export involves hierarchical page content, proprietary templating code (HubL), relational databases (HubDB), unstructured file assets, and dynamic page generation logic.

There is no single "export site" button. If you are migrating to WordPress, Webflow, Contentful, or any other platform, relying solely on native UI exports will leave you with missing body content, broken image links, flattened database relationships, and raw HubL code rendering on your live pages.

This guide covers exactly how to extract every layer of your HubSpot CMS, the technical constraints of each method, the actual API response structures you'll encounter, and the portability traps that catch teams mid-migration.

What Data Lives in HubSpot CMS Hub?

HubSpot stores web content across several distinct systems, each with its own export path and its own blind spots.

Content Type Native UI Export API Export Key Gaps
Blog Posts ✅ CSV (metadata only), HTML bulk GET /cms/blogs/2026-03/posts CSV omits body content, meta descriptions, featured images
Website Pages ✅ HTML bulk export GET /cms/v3/pages/site-pages HTML export loses structured layoutSections data
Landing Pages ✅ HTML bulk export GET /cms/v3/pages/landing-pages Same layoutSections caveat
HubDB Tables ✅ CSV from table UI GET /cms/v3/hubdb/tables/{id}/rows CSV flattens foreign keys; 10,000 row max per table
Files & Assets ✅ Individual download ✅ Files API URLs tied to HubSpot CDN — must re-host
Themes & Modules ✅ Via Design Manager ✅ Source Code API / CLI HubL code not portable to other platforms
Forms ❌ No bulk form export ✅ Forms API Form submissions exportable separately
URL Redirects ✅ CSV from Settings ✅ URL Redirects API Large sets need pagination
Warning

CMS Hub vs. Content Hub naming: HubSpot renamed CMS Hub to Content Hub in April 2024. The underlying CMS — website builder, hosting, themes, HubDB — is the same product. Existing CMS Hub customers were migrated automatically. The APIs are unchanged. This guide uses "CMS Hub" because that's still the dominant search term among engineers.

Method 1: Native UI Exports

The HubSpot UI provides limited export capabilities. These are suitable for inventories, archives, and flat data extraction — but not for structured migrations.

Blog CSV Export

The fastest path to a blog inventory is the built-in CSV export.

  1. Navigate to Content > Blog
  2. Click ActionsExport blog posts
  3. Select CSV, XLS, or XLSX
  4. HubSpot emails you a download link

This gives you post titles, URLs, publish dates, and authors in spreadsheet format. It does not include the full body content, meta descriptions, or featured image URLs.

Use the CSV export as a verification checklist — to confirm you have every post — not as your primary migration method.

HTML Bulk Export

On the content index page, click the Actions dropdown and select Export all pages and blog posts (HTML). Once processed, you'll receive a download link through email and in your notification center.

This produces rendered HTML files with template files placed in the Styles folder. It is useful for archival or content parity checks, but not for structured migration. The HTML export flattens everything — you lose layoutSections structure, module-level field values, and dynamic content powered by HubDB or smart rules.

Tip

When the UI export is enough: Archiving a site for compliance, creating a static backup, or verifying content parity after migration. If you're rebuilding on another platform and need structured content, use the API.

HubDB CSV Export

HubDB tables can be exported from the UI as CSV or XLSX. Navigate to the table and use Actions > Export.

The trade-off: HubDB supports linked rows (foreign keys referencing other tables). A CSV export flattens these relationships into text strings. If you need to rebuild a relational database in your target CMS — Webflow CMS Collections, Contentful, or similar — you lose structural integrity. Use the API to preserve relational IDs.

Report-Based Page Exports

You can export page data from Reporting > Reports > Web traffic analysis > Pages, but this export contains page-level data and metrics only — not full HTML content. A Marketing Hub or Content Hub Professional or Enterprise subscription is required for unsummarized page data.

This is an analytics export, not a content export. Useful for URL inventories and traffic analysis, not for migration.

Method 2: Developer Assets via CLI and Source Code API

Themes, templates, and custom modules live in HubSpot's Developer File System — separate from both the content APIs and the file manager. Neither the standard UI export nor the REST APIs for pages will give you this code.

HubSpot CLI

Install via npm and authenticate with your portal:

npm install -g @hubspot/cli
hs auth

Then fetch your theme and module code:

# Fetch an entire theme directory
hs fetch @hubspot/my_custom_theme ./local_theme_backup
 
# Fetch specific custom modules
hs fetch /my_custom_theme/modules ./local_modules
Warning

CLI version matters: HubSpot CLI v8 moved several commands under the hs cms namespace. Older hs fetch syntax may not work in newer CLI versions. Check your installed version and consult HubSpot's CLI documentation for current commands.

Source Code API

Alternatively, the Source Code API lets you retrieve developer file system assets programmatically. You cannot download an entire folder in one call — you must fetch folder metadata and recursively retrieve children.

The Portability Trap

The code you download is written in HubL — HubSpot's proprietary markup language — and relies on HubSpot's rendering engine. You cannot upload a HubSpot theme to WordPress, Shopify, or Webflow. The value of this extraction is reference: your developers can see the logic, CSS, and module schemas so they can rebuild them in the target system.

Method 3: CMS APIs for Full-Fidelity Extraction

For a real migration, you need the CMS APIs. This is the only way to get structured page data, preserve database relationships, and automate the extraction pipeline.

Blog Posts API

Retrieve all blog posts via GET /cms/blogs/2026-03/posts. The response includes the full HTML body (postBody), meta description, featured image URL, author, tags, publish date, slug, and SEO settings.

curl 'https://api.hubapi.com/cms/blogs/2026-03/posts?limit=100&state=PUBLISHED' \
  --header 'Authorization: Bearer YOUR_ACCESS_TOKEN'

A truncated example of a blog post API response:

{
  "id": "12345678901",
  "slug": "how-to-export-hubspot-data",
  "htmlTitle": "How to Export Data from HubSpot",
  "postBody": "<div class=\"blog-post-body\">\n<h2>Getting started</h2>\n<p>Here is the body content...</p>\n{% module \"cta_module\" path=\"/themes/my_theme/modules/cta\" %}\n</div>",
  "metaDescription": "A complete guide to exporting HubSpot CMS data.",
  "featuredImage": "https://f.hubspotusercontent40.net/hubfs/123/image.jpg",
  "publishDate": "2025-06-01T10:00:00.000Z",
  "blogAuthorId": "987654321",
  "tagIds": ["111", "222"],
  "state": "PUBLISHED",
  "paging": {
    "next": {
      "after": "NTI1Cg%3D%3D"
    }
  }
}

Paginate using the after cursor from paging.next.after. The default page size for blog posts is 20 — set limit=100 to reduce round trips.

Author data gotcha: The authorName field returns the name of the user who most recently published the post, not the blog author profile. To preserve real authorship, resolve blogAuthorId against the Blog Authors API (GET /cms/blogs/2026-03/authors/{id}). If tag landing pages matter in the destination CMS, export tags separately — blog tags are still documented under /cms/v3/blogs/tags, not the date-versioned path.

Warning

Date-versioned API paths: HubSpot adopted date-based versioning (e.g., /cms/blogs/2026-03/) starting March 2026. Older /cms/v3/ paths still work but may be deprecated. Not every CMS endpoint has moved to the new format yet. Check the HubSpot API changelog for current paths.

Website Pages and Landing Pages API

Retrieve pages via GET /cms/v3/pages/site-pages or GET /cms/v3/pages/landing-pages. Filter published pages with state__in=PUBLISHED_OR_SCHEDULED.

curl 'https://api.hubapi.com/cms/pages/2026-03/site-pages?state__in=PUBLISHED_OR_SCHEDULED&limit=100&property=id&property=name&property=url&property=slug&property=templatePath&property=layoutSections&property=translations' \
  --header 'Authorization: Bearer YOUR_TOKEN'

A truncated example of a layoutSections response from a drag-and-drop page:

{
  "id": "98765432101",
  "name": "Product Features Page",
  "slug": "features",
  "state": "PUBLISHED",
  "layoutSections": {
    "dnd_section_1": {
      "type": "cell",
      "rows": [
        {
          "0": {
            "cells": [
              {
                "0": {
                  "widgets": [
                    {
                      "id": "widget_abc123",
                      "type": "rich_text",
                      "body": {
                        "html": "<h2>Our core features</h2><p>Description here...</p>"
                      }
                    },
                    {
                      "id": "widget_def456",
                      "type": "linked_image",
                      "body": {
                        "img": {
                          "src": "https://f.hubspotusercontent40.net/hubfs/123/feature.jpg",
                          "alt": "Feature diagram"
                        },
                        "href": "/learn-more"
                      }
                    }
                  ]
                }
              }
            ]
          }
        }
      ]
    }
  }
}

The layoutSections problem: For pages built with HubSpot's drag-and-drop editor, the content is not in a single HTML body field. It's distributed across nested layoutSections objects — sections, rows, columns, and modules — each containing their own field values. You need to traverse this structure, extract content from each module, and convert it to your target CMS's content format.

This is the single biggest engineering challenge in HubSpot CMS export. Here is the traversal logic and an example mapping to Contentful and Webflow formats:

def extract_modules(layout_sections):
    modules = []
    for section_key, section in layout_sections.items():
        for row_key, row in section.get('rows', [{}])[0].items():
            for cell in row.get('cells', {}).values():
                for widget in cell.get('widgets', []):
                    widget_type = widget.get('type')
                    body = widget.get('body', {})
                    
                    if widget_type == 'rich_text':
                        modules.append({
                            'type': 'rich_text',
                            'html': body.get('html', ''),
                            # Contentful: maps to 'richText' field type
                            # Webflow: maps to Rich Text element
                        })
                    elif widget_type == 'linked_image':
                        img = body.get('img', {})
                        modules.append({
                            'type': 'image',
                            'src': img.get('src', ''),
                            'alt': img.get('alt', ''),
                            'href': body.get('href', ''),
                            # Contentful: maps to 'Asset' with link field
                            # Webflow: maps to Image element with Link wrapper
                        })
                    elif widget_type == 'custom_widget':
                        # Custom modules require manual schema mapping
                        modules.append({
                            'type': 'custom',
                            'module_id': widget.get('id'),
                            'fields': body,
                            'requires_manual_mapping': True
                        })
    return modules

Filtering edge case: currentState is a generated field and cannot be used as a filter parameter. To separate scheduled from published content, use state__in=PUBLISHED_OR_SCHEDULED with publishDate__gt or publishDate__lt. The page model also exposes multi-language and A/B test relationships through translatedFromId, translations, abTestId, and abStatus.

Fetching all language variants of a page: To retrieve every language variant, first fetch the primary page to get its id, then query GET /cms/v3/pages/site-pages?translatedFromId={primaryPageId}. This returns all child translations. Each variant's language field identifies the locale (e.g., "fr", "de"). Build a map of primaryPageId → [variantId, locale] tuples during extraction so you can reconstruct language groups in the target CMS.

HubDB Tables API

HubDB is HubSpot's built-in relational table store. If your site uses HubDB-driven dynamic pages, you need more than a simple page export.

Extract the schema first to understand column types:

GET https://api.hubapi.com/cms/v3/hubdb/tables/{tableId}

A truncated schema response:

{
  "id": "5678901",
  "name": "products",
  "columns": [
    { "id": "1", "name": "name", "type": "TEXT" },
    { "id": "2", "name": "price", "type": "NUMBER" },
    { "id": "3", "name": "category_id", "type": "FOREIGN_ID", "foreignTableId": "5678902" },
    { "id": "4", "name": "description", "type": "RICHTEXT" }
  ]
}

Then extract rows:

GET https://api.hubapi.com/cms/v3/hubdb/tables/{tableId}/rows?limit=1000

A truncated row response showing a foreign key:

{
  "results": [
    {
      "id": "1",
      "values": {
        "1": "Widget Pro",
        "2": 49.99,
        "3": { "id": "42", "type": "foreignid" },
        "4": "<p>Our flagship product...</p>"
      }
    }
  ]
}

The "3" column returns { "id": "42", "type": "foreignid" } — the row ID from the referenced table. Maintain an identity map during migration: { hubspot_row_id: target_cms_record_id }. The CSV export from the UI flattens column "3" to a plain text string (e.g., "42"), losing the type context. If you are rebuilding in Webflow CMS, this maps to a Reference field; in Contentful, to a Link to Entry.

Documented limits: 1,000 tables per account, 1 million rows per account, 250 columns per table, 10,000 rows per table, 10 dynamic pages per table, and 65,000 characters per rich text column. The default API page size is 1,000 rows.

Rate limiting difference: Unauthenticated public GET requests against public HubDB tables are capped at 10 requests per second and do not count against the daily limit. Authenticated requests fall under standard app limits.

Danger

Dynamic pages: If a page uses dynamicPageHubDbTableId, export the page shell and the HubDB table together. You need the table schema, rows, hs_path, hs_name, and any dynamic metadata mapping to recreate the generated URLs on your target platform.

Files API

Images, PDFs, and other media in HubSpot's file manager are accessible via the Files API. Every file URL points to HubSpot's CDN (f.hubspotusercontent*.net).

Private files require a signed URL or the direct download endpoint — the normal metadata URL will 404 for private assets. Hidden files are not returned by list operations and require the files.ui_hidden.read scope when fetched by ID. If your migration depends on hidden or private assets, test that path early.

Forms and URL Redirects

A page can render perfectly in HTML and still be incomplete as a migration artifact if you miss forms and redirects.

Forms: Export form definitions (fields, validation rules, redirect URLs) via the Forms API. Historical form submissions are available from the UI (Site Settings) or via the submissions endpoint. Form definitions and submissions are separate — capture both if needed.

URL Redirects: Export from Settings > Content > URL Redirects as CSV, or via the URL Redirects API. The API returns routePrefix (old URL) and destination (new URL). Format the output for your target hosting provider — .htaccess, vercel.json, Webflow's 301 redirect CSV, or whatever the platform requires.

Warning

All HubSpot forms lose their paid features once your account is deactivated. If you're canceling, replace embedded forms on external sites before account closure.

The HubL Problem: Why Template Code Isn't Portable

The most severe portability constraint in HubSpot CMS export is HubL — HubSpot's proprietary markup language. HubL uses Jinja2-style syntax and is executed server-side by HubSpot's rendering engine. It is not supported by any other CMS or web framework.

When you query the Pages or Blog API, the html field is not always static HTML. It can contain dynamic macros:

<h1>Welcome to our site</h1>
{% module "my_custom_cta" path="/themes/my_theme/modules/cta", label="Primary CTA" %}
{% widget_block rich_text "intro_text" %}
  <p>Default content here</p>
{% end_widget_block %}
<p>{{ custom_company_variable }}</p>
{% if contact.lifecyclestage == "customer" %}
  <div class="customer-only">...</div>
{% endif %}

Push this raw payload into another CMS and the HubL tags will render as visible broken text or break the page layout entirely.

HubL tag categories your parser must handle:

Tag Pattern Description Handling Strategy
{% module "name" path="..." %} Inline module insertion Extract path to identify module type; map to target component
{% widget_block type "name" %}...{% end_widget_block %} Block-level widget with default content Extract inner HTML as fallback content
{{ variable }} Variable interpolation Strip or replace with static default
{% if condition %}...{% endif %} Conditional rendering Keep default branch; document smart-content variants
{% for item in items %}...{% endfor %} Loop constructs Strip; document that dynamic content requires rebuild
{# comment #} HubL comments Safe to strip

Parsing approach: Regex is sufficient for stripping simple variable interpolation ({{ ... }}) and comments ({# ... #}). For block constructs ({% ... %}...{% end_... %}), use a recursive descent parser or a Jinja2-compatible tokenizer (since HubL syntax is a superset of Jinja2). The Python jinja2 library can tokenize most HubL constructs without executing them — use jinja2.Environment().parse() to walk the AST and identify node types for targeted extraction or removal.

For {% module %} tags specifically, the path attribute identifies which custom module is being invoked. Cross-reference the path against your CLI-extracted theme files to find the module's field schema (fields.json) and reconstruct equivalent content from its default field values.

The CDN Asset Trap

When you export pages or blogs via any method, image URLs in the HTML point to HubSpot's CDN (f.hubspotusercontent*.net).

If you migrate your site but leave these URLs in the content, images will load fine — until you cancel your HubSpot subscription. The moment the account closes, the CDN links die and every image on your new site returns a 404.

The fix:

  1. List all files via the Files API
  2. Download each binary (accounting for private and hidden files)
  3. Upload to your new CDN (AWS S3, Cloudinary, or the target CMS's asset manager)
  4. Run a find-and-replace across all blog post bodies, page HTML, and layoutSections content to swap every hubspotusercontent*.net URL for the new asset URL

This step is easy to forget and painful to fix after launch. The regex pattern to identify HubSpot CDN URLs: https?://f\.hubspotusercontent\d+\.net/[^\s"'<>]+

HubSpot API Rate Limits for CMS Export

Rate limits are the primary constraint on API-based extraction. Limits vary by subscription tier and app type.

Private App Rate Limits

Subscription Tier Burst Limit (per 10s, per app) Daily Limit (per account)
Free / Starter 100 requests 250,000
Professional 190 requests 625,000
Enterprise 190 requests 1,000,000
API Limit Increase add-on 250 requests +1,000,000 (max 2 purchases)

Source: HubSpot API usage guidelines

The burst limit applies individually per app. The daily limit is shared across all apps within the same HubSpot account. If you have multiple integrations running alongside your export script, they compete for the same daily quota.

Public OAuth apps distributed via the HubSpot Marketplace are limited to 110 requests per 10 seconds per portal.

CRM Search API is capped at 4 requests per second shared across all search endpoints — not per endpoint.

Error Handling and Retry Patterns

HubSpot returns HTTP 429 when you exceed the burst limit. The response includes a Retry-After header specifying how many seconds to wait. Do not retry immediately — implement exponential backoff:

import time
import requests
 
def hubspot_get(url, token, max_retries=5):
    for attempt in range(max_retries):
        response = requests.get(url, headers={"Authorization": f"Bearer {token}"})
        
        if response.status_code == 200:
            return response.json()
        elif response.status_code == 429:
            retry_after = int(response.headers.get("Retry-After", 10))
            wait = retry_after * (2 ** attempt)  # exponential backoff
            print(f"Rate limited. Waiting {wait}s (attempt {attempt + 1}/{max_retries})")
            time.sleep(wait)
        elif response.status_code == 504:
            # Gateway timeout on large paginated responses — retry with smaller limit
            wait = 5 * (2 ** attempt)
            print(f"Gateway timeout. Waiting {wait}s before retry")
            time.sleep(wait)
        elif response.status_code in (401, 403):
            raise Exception(f"Auth error {response.status_code}: token invalid or missing scope")
        else:
            raise Exception(f"Unretryable error {response.status_code}: {response.text}")
    
    raise Exception(f"Max retries exceeded for {url}")

HubSpot API error codes relevant to CMS export:

HTTP Code Meaning Retryable?
429 Rate limit exceeded Yes — honor Retry-After
504 Gateway timeout (large requests) Yes — reduce limit param
401 Invalid or expired token No — re-authenticate
403 Insufficient scope No — add scope to private app
404 Resource not found (deleted page, hidden file) No — skip and log

Token expiry: Private app tokens in HubSpot do not expire on a time schedule — they remain valid until manually rotated. However, if a token is revoked mid-export (e.g., by an admin), all subsequent calls return 401. Build your extraction script to catch 401 responses distinctly from 429s and halt immediately rather than retrying.

Pagination

HubSpot uses cursor-based pagination. Responses include a paging.next.after token. Append it to subsequent requests (&after=TOKEN). Do not rely on offset-based pagination — it is deprecated for most v3 endpoints.

Practical Impact on Export Time

For a typical CMS export at the Free/Starter rate limit (100 req/10s):

  • 100 blog posts: ~2 API calls at limit=100. Completes in under 5 seconds.
  • 1,000 website pages: ~10 API calls for the page list. Completes in under 30 seconds.
  • 10,000 blog posts: ~100 API calls. Completes in approximately 10–15 seconds of API time; wall-clock time ~2–3 minutes with processing overhead.
  • 50,000 files: Each file requires a separate download call. At 100 req/10s burst, listing takes ~500 calls. Downloading binaries is I/O-bound, not API-bound. Expect 2–6 hours depending on file sizes and network throughput.

The CMS content extraction itself is rarely the bottleneck. File download and content transformation are where the time goes.

Info

Batch endpoints count as one request. HubSpot's batch create/update endpoints process up to 100 records per call, counting as a single request against your rate limit. Use batch endpoints where available.

Incremental and Delta Export

If you need to re-export changes since a specific date — for partial migrations, content sync, or re-runs after a failed job — filter by update timestamp:

# Export only posts updated after a specific date
curl 'https://api.hubapi.com/cms/blogs/2026-03/posts?updatedAt__gt=2025-01-01T00:00:00.000Z&limit=100' \
  --header 'Authorization: Bearer YOUR_TOKEN'
 
# Export only pages published in a date range
curl 'https://api.hubapi.com/cms/v3/pages/site-pages?publishDate__gt=2024-06-01T00:00:00.000Z&publishDate__lt=2024-12-31T23:59:59.000Z&limit=100' \
  --header 'Authorization: Bearer YOUR_TOKEN'

Delta export pattern for ongoing sync:

  1. Record the timestamp of your last successful export run.
  2. On each subsequent run, query updatedAt__gt={last_run_timestamp}.
  3. Update your local data store with changed records (upsert by id).
  4. Advance the timestamp checkpoint only after confirming all records were processed.

This pattern works for blog posts and pages. HubDB does not expose an updatedAt filter on row queries — for HubDB delta sync, maintain a local copy and diff by row id against a full re-export.

Step-by-Step: Full CMS Hub Export via API

Step 1: Create a Private App and Set Scopes

In HubSpot, go to Settings > Integrations > Private Apps > Create a private app. Grant scopes:

  • content — blog posts, pages, landing pages
  • hubdb — HubDB table access
  • files — file manager access
  • files.ui_hidden.read — hidden file access
  • forms — form definitions and submissions

Copy and securely store the access token. Private app tokens do not expire on a schedule but should be rotated if exposed.

Step 2: Download Developer Assets

Use the CLI or Source Code API to pull down themes, templates, custom modules, CSS, and JavaScript. This code is reference material for rebuilding on the target platform — not directly importable elsewhere. Specifically capture fields.json from each custom module; this file defines the module's configurable fields and is essential for reconstructing content from API-returned field values.

Step 3: Export Blog Posts

Paginate through GET /cms/blogs/2026-03/posts?limit=100&state=PUBLISHED using the after cursor. Store each post's id, slug, htmlTitle, postBody, metaDescription, featuredImage, blogAuthorId, tagIds, and publishDate. Resolve blogAuthorId against GET /cms/blogs/2026-03/authors/{id} for correct attribution — do not rely on authorName.

Step 4: Export Website Pages and Landing Pages

Hit both GET /cms/v3/pages/site-pages and GET /cms/v3/pages/landing-pages with state__in=PUBLISHED_OR_SCHEDULED. Capture the full JSON including layoutSections. Run the traversal logic above to extract text, images, CTAs, and custom module field values. For each page with translatedFromId set, fetch language variants using translatedFromId as a filter parameter.

Step 5: Export HubDB Tables

List all tables via GET /cms/v3/hubdb/tables, then export rows for each via GET /cms/v3/hubdb/tables/{tableId}/rows?limit=1000. Store schema definitions (column names, types, foreign keys) alongside row data. Build an identity map of hubspot_row_id → new_platform_record_id before inserting into the target system. For dynamic pages, capture hs_path and hs_name fields.

Step 6: Download Files and Build a URL Map

List all files via the Files API. Handle private files via the signed URL endpoint. Handle hidden files using the files.ui_hidden.read scope. Download each binary. Build a mapping table: old_hubspot_url → new_cdn_url. This map is required for Step 8.

Step 7: Export Forms and URL Redirects

Retrieve form definitions via the Forms API. Export historical submissions separately if needed. Pull URL redirects from Settings or the Redirects API. Format for your target hosting environment.

Step 8: Rewrite Asset URLs

After uploading files to your new CDN, run a find-and-replace across all blog post bodies, page HTML, and layoutSections content. Use the regex https?://f\.hubspotusercontent\d+\.net/[^\s"'<>]+ to identify URLs, then replace using your URL map from Step 6.

Step 9: Parse HubL and Map to Target Schema

Run extracted content through a tokenizer to identify HubL tags by category. Strip or replace according to the tag table in the HubL section above. Map {% module %} tags to equivalent target CMS components using each module's fields.json schema. This is the most labor-intensive step for sites with custom modules.

What Doesn't Export Cleanly

Every HubSpot CMS migration hits at least one of these edge cases:

Smart content and personalization rules. HubSpot's smart content — content that changes based on visitor segment, device, or referral source — has no structured export. The API returns the default variant only. Conditional variants and targeting rules must be manually documented and recreated.

Multi-language content groups. HubSpot groups language variants via translatedFromId. Rebuilding this grouping on a target platform requires mapping every variant back to its primary page using the filter described in Step 4.

A/B test variants. Active A/B tests export as separate page variants with distinct IDs linked via abTestId. Decide before export whether to keep the winning variant only or preserve both.

Historical revision history. Blog posts have revision endpoints (list, fetch, restore). Pages do not have equivalent revision endpoints in current documentation. Verify page-history requirements before committing to a migration approach.

Membership and gated content. No bulk export path. Must be documented manually and recreated.

CRM-integrated personalization. Tied to HubSpot CRM contact data at render time. Not portable to any external system.

Data Portability Summary

What you keep:

  • Blog post content (HTML body, metadata, SEO fields)
  • Page content (via API, including layoutSections JSON)
  • HubDB structured data (schemas and rows with relational IDs)
  • Files and media (must download before cancellation)
  • Form definitions and submission history
  • URL redirect rules

What you lose:

  • HubL template logic (must rebuild using extracted module schemas)
  • Smart content targeting rules (must document manually)
  • CRM-integrated personalization
  • Historical revision history for pages
  • Membership/gated content configuration
  • Connected CRM attribution (page views → contacts)

You can't fetch your HubSpot site in its entirety in a single click. But you can export the different data types that make up your website. The content is portable. The platform logic is not.

When to DIY vs. Get Help

Scenario Recommendation Estimated Effort
Small blog (<200 posts), no HubDB CSV export + API for bodies. Self-serve. 2–4 hours
Medium site (200–1,000 pages), simple templates API extraction with basic scripting. 1–2 engineer-days
Large site (1,000+ pages) with drag-and-drop pages layoutSections parsing required. 3–5 engineer-days
HubDB-driven dynamic pages Schema extraction + identity map + content transformation. 2–3 days
Multi-language site Language variant mapping adds complexity per locale. +1–2 days per language group
10,000+ files Bulk download + URL rewriting. Automation required. 1–2 days
Smart content, personalization, A/B tests Manual audit + recreation. No clean export path. Project-specific

If your site is a straightforward blog with standard templates, the API and a weekend of scripting will get you there. Once you add drag-and-drop pages, HubDB relationships, multi-language groups, and tens of thousands of files, the complexity scales non-linearly — primarily because each layer requires a custom transformation step with no standardized tooling.

For a related walkthrough of CRM-side export, see our guide on exporting data from HubSpot Service Hub.

Key Decisions Before You Start

  1. Do you need body content or just metadata? CSV is sufficient for metadata only.
  2. Are pages built with drag-and-drop or custom-coded templates? Drag-and-drop means layoutSections traversal. Custom-coded templates mean simpler HTML body extraction but heavier HubL parsing.
  3. How many files? Under 1,000 is manageable manually. Over 10,000 requires automation and retry logic.
  4. Do you have HubDB dynamic pages? These require schema + row export + identity mapping, not just a page export.
  5. Are you doing a one-time migration or ongoing sync? One-time: full export. Ongoing: implement delta export using updatedAt__gt filters.
  6. Keeping HubSpot or leaving entirely? If leaving, download all files and replace embedded forms before account cancellation — CDN links and form functionality die with the account.

Frequently Asked Questions

How do I export blog posts from HubSpot CMS Hub with full content?
The native CSV export only includes titles, URLs, and dates — not body content. To get full HTML bodies, use the Blog Posts API: GET /cms/blogs/2026-03/posts. Paginate with the 'after' cursor and store postBody, metaDescription, and featuredImage for each post. Resolve blogAuthorId via the Blog Authors API for correct attribution.
Can I export HubSpot page layouts and drag-and-drop content?
Yes, but it's complex. Pages built with the drag-and-drop editor store content in nested layoutSections JSON objects, not a single HTML field. You need to traverse this structure via the Pages API and extract content from each module, section, and column. The HTML bulk export flattens this structure and loses it.
What happens to HubSpot CMS files after I cancel my account?
All images and files hosted on HubSpot's CDN (f.hubspotusercontent*.net URLs) return 404 errors after account cancellation. You must download all files via the Files API and re-host them on your new CDN before canceling, then update all content references to the new URLs.
How do I export HubSpot templates and custom modules?
Templates, themes, and custom modules live in the Developer File System — not accessible via the standard content APIs. Use the HubSpot CLI or the Source Code API to download the code. Note that the code is written in HubL and cannot be directly used on any other CMS platform.
What are HubSpot's API rate limits for CMS content export?
Private apps on Free/Starter get 100 requests per 10 seconds and 250,000 per day. Professional gets 190/10s and 625K/day. Enterprise gets 190/10s and 1M/day. The burst limit is per app; the daily limit is shared across all apps in the account.

More from our Blog