---
title: "Bloomreach to Textpattern Migration: A Technical Guide"
slug: bloomreach-to-textpattern-migration-a-technical-guide
date: 2026-08-21
author: Nachi
categories: [Migration Guide, General]
excerpt: "A technical guide to migrating from Bloomreach to Textpattern — covering API extraction, content flattening, MySQL loading, and media re-hosting."
tldr: "Bloomreach to Textpattern requires extracting NDJSON via the Batch Export API, flattening rich document types into Textpattern's flat MySQL schema, and loading via direct SQL — no native path exists."
canonical: https://clonepartner.com/blog/bloomreach-to-textpattern-migration-a-technical-guide/
---

# Bloomreach to Textpattern Migration: A Technical Guide


## What Changes When You Move from Bloomreach to Textpattern

A **Bloomreach to Textpattern migration** moves structured content from an enterprise headless CMS into a deliberately minimal PHP/MySQL publishing system. The architectural gap is severe. Bloomreach manages content through channels, document types, compound fields, and page layouts — all accessible via Management APIs and a Delivery API. Textpattern stores everything in a flat MySQL schema: articles in the `textpattern` table, image metadata in `txp_image`, files in `txp_file`, and categories in `txp_category`.

Textpattern has no write API and no import wizard. Every migration requires custom ETL scripts that extract from Bloomreach's export surfaces and load directly into MySQL.

Teams make this move to shed enterprise licensing costs, eliminate Java stack dependencies, or consolidate to a lightweight publishing tool where a small editorial team has direct database-level control. The trade-off is permanent: you lose multi-channel delivery, content personalization, structured document types, headless API delivery, and programmatic content workflows. You gain a fast, minimal system with zero external dependencies and full MySQL-level control over every piece of content.

> [!WARNING]
> **No native migration path exists.** Bloomreach exports content as NDJSON via its Content Batch Export API. Textpattern has no write API — data must be inserted directly into MySQL. Every migration requires custom extraction and loading scripts.

**Estimated effort by site complexity:**

| Site profile | Estimated effort |
|---|---|
| <100 documents, single channel, no custom compound types | 2–3 days |
| 100–1,000 documents, multiple document types, image assets | 4–7 days |
| 1,000+ documents, multiple channels, compound types, personalization variants, strict SEO requirements | 1–3 weeks |

These estimates assume one engineer with Python/SQL proficiency, a Textpattern staging instance running, and access to Bloomreach's Management APIs with a valid auth token. The dominant time cost at scale is not extraction — it's field mapping decisions and HTML cleanup on body content that contains unresolved internal references.

## Which Bloomreach Product You're Migrating From

"Bloomreach" refers to distinct products with different export surfaces. Confusing them means writing the wrong extractor and misreading what "page" and "document" mean in the source system.

**Bloomreach Content (SaaS)** is a headless, API-first platform with Management APIs (Content Batch Export, Content Management, Content Type Management) and a read-only Delivery API. Content is organized into **channels**, which act as the root entity in the site configuration model. Its content model consists of:

- **Documents** — structured content items defined by document types. Each document type defines its own schema with primitive fields (string, boolean, date) and compound fields (rich text, image links, content references).
- **Pages** — layout entities containing component hierarchies that reference documents.
- **Resource bundles** — key-value pairs for translations and configuration strings.
- **Folders** — organizational containers. Metadata lives on documents, not folders.

**Bloomreach Experience Manager (brXM)** is the older self-hosted/PaaS deployment model built on a Java Content Repository (JCR). Published content is exposed through a Content REST API on the delivery tier, while repository-level exports use JCR console/XML tooling. ([xmdocumentation.bloomreach.com](https://xmdocumentation.bloomreach.com/library/concepts/rest/content-rest-api/introduction.html))

> [!NOTE]
> **brXM users:** The Content REST API does not have authentication by default. If your instance hasn't added custom auth, all published content is openly accessible. Verify this before assuming you need credentials.

## Textpattern's Data Model: What You're Loading Into

Textpattern's schema is radically simpler than Bloomreach's. All content lives in MySQL tables with fixed columns. These constraints are not configuration choices — they are hard schema limits that determine what content survives the migration.

### The `textpattern` Table (Articles)

This is where all your Bloomreach documents will land. Key columns:

| Column | Type | Notes |
|---|---|---|
| `ID` | INT | Auto-increment primary key |
| `Title` | VARCHAR(255) | Article title |
| `Body` | MEDIUMTEXT | Raw body content (Textile, HTML, or plain text) |
| `Body_html` | MEDIUMTEXT | Pre-rendered HTML version — must not be left blank if textile_body=0 |
| `Excerpt` | TEXT | Max 64KB |
| `Excerpt_html` | MEDIUMTEXT | Pre-rendered HTML of excerpt |
| `Section` | VARCHAR(255) | Exactly one section per article |
| `Category1` | VARCHAR(64) | First category |
| `Category2` | VARCHAR(64) | Second category — hard maximum of two |
| `Status` | INT | 1=draft, 2=hidden, 3=pending, 4=live, 5=sticky |
| `textile_body` | VARCHAR(32) | 0=raw HTML, 1=Textile, 2=convert line breaks |
| `custom_1` through `custom_10` | VARCHAR(255) | Only 10 custom fields, each capped at 255 characters |
| `Keywords` | VARCHAR(255) | Comma-separated, 255 char limit total |
| `url_title` | VARCHAR(255) | URL slug |
| `AuthorID` | VARCHAR(64) | Must match a `name` in `txp_users` — orphaned articles appear in admin but may break views |
| `uid` | VARCHAR(32) | Unique identifier, required for RSS/Atom feeds — missing causes silent feed breakage |
| `feed_time` | DATE | Required for RSS/Atom feeds — missing causes silent feed breakage |

([docs.textpattern.com](https://docs.textpattern.com/development/database-schema-reference))

### Hard Limits That Will Break Your Migration If Ignored

These are the constraints most often discovered late in a migration and are worth enumerating before writing a single line of transformation code:

- **Two categories maximum per article.** Bloomreach documents with multiple taxonomy assignments lose all but two. There is no workaround within core Textpattern — categories 3 through N are simply dropped.
- **10 custom fields, each VARCHAR(255).** Bloomreach compound fields, nested objects, and long-form metadata don't fit. The `glz_custom_fields` plugin (maintained as of Textpattern 4.8.x) can extend this with unlimited fields of various data types including textarea, date, and select, but it introduces a plugin dependency that must be evaluated before commit.
- **No content references.** Bloomreach's inter-document linking (content pickers, document references) has no equivalent. These must be flattened to plain text or hyperlinks during transformation, or they will appear as raw UUID strings in your article body.
- **Single section per article.** Bloomreach documents can appear on multiple pages across channels. In Textpattern, each article belongs to exactly one section — a structural, not cosmetic, constraint.
- **64KB page template limit.** Each Textpattern page template can store at most 64KB of template code, which makes flattening a component-driven Bloomreach experience into a single template unworkable. ([docs.textpattern.com](https://docs.textpattern.com/administration/pages-panel))
- **`Body_html` must be populated when `textile_body=0`.** If you insert raw HTML into `Body` and leave `Body_html` empty, the article renders blank on the frontend. This failure is silent and easy to miss until frontend QA.

## Extracting Content from Bloomreach

### SaaS: Content Batch Export API

The **Content Batch Export API** is the primary extraction tool for Bloomreach Content SaaS. Key facts:

- It exports documents, pages, resource bundles, and folders from a specified folder path.
- Output format is a **zip file containing NDJSON** (newline-delimited JSON) — one JSON object per line, per content item.
- It's an **asynchronous** operation: you POST a request, receive an `operationId`, then poll for status until the export completes.
- **Images and assets are not included.** You must export media separately via a distinct workflow.
- The API supports a `modifiedAfter` timestamp for delta extraction — critical for cutover workflows where content continues to be authored during migration.
- The API requires **token authorization**.

([documentation.bloomreach.com](https://documentation.bloomreach.com/content/reference/content-batch-export-api))

Here's the extraction flow:

```bash
# 1. Start the export job
curl -X POST \
  'https://{account}.bloomreach.io/management/content-export/v1/export' \
  -H 'Authorization: Bearer {token}' \
  -H 'Content-Type: application/json' \
  -d '{
    "sourcePath": "/content/documents/my-channel",
    "dataTypes": ["document", "page", "resourcebundle", "folder"]
  }'

# Response includes operationId
# {"operationId": "abc-123", "status": "STARTING", ...}

# 2. Poll for completion
curl -X GET \
  'https://{account}.bloomreach.io/management/content-export/v1/export/abc-123' \
  -H 'Authorization: Bearer {token}'

# 3. When status is COMPLETED, download the NDJSON zip
curl -X GET \
  'https://{account}.bloomreach.io/management/content-export/v1/export/abc-123/result' \
  -H 'Authorization: Bearer {token}' \
  -o export.zip
```

Use the **Delivery API 2.0** alongside batch export for audits, count checks, and pre-cutover delta validation. It provides queryable access to documents with `limit`, `offset`, field filters, `orderBy`, and `lastModified(gt)` filtering. Folder traversal has a `limit <= 100`, so large repositories need explicit pagination. ([documentation.bloomreach.com](https://documentation.bloomreach.com/content/reference/documents-delivery-api))

### brXM (Self-Hosted): Content REST API

For brXM, the Content REST API exposes all published content based on document types. Paginate with `_offset` and `_max` parameters (default and maximum page size: 100):

```python
import requests

base_url = "https://your-site.com/site/api/documents"
all_docs = []
offset = 0
page_size = 100

while True:
    resp = requests.get(base_url, params={
        "_offset": offset,
        "_max": page_size
    })
    items = resp.json().get("items", [])
    if not items:
        break
    all_docs.extend(items)
    offset += page_size
```

If the brXM site uses Experience Pages, Delivery API 1.0 is often the better extraction surface because page responses can serialize referenced documents, image sets, and assets together with the page model. Reach for JCR console export only when you need unpublished state, repository-only metadata, or an exact node hierarchy.

### Pre-Migration Audit: Quantify Data Loss Before You Commit

Before writing transformation code, run a structured field inventory against your Bloomreach export. This step prevents discovering irreversible data loss after loading into staging.

For each document type, capture:

```python
import json
from collections import defaultdict

field_usage = defaultdict(lambda: {"count": 0, "max_length": 0, "types": set()})

with open("export.ndjson") as f:
    for line in f:
        doc = json.loads(line)
        for field_name, field_value in doc.get("data", {}).items():
            field_usage[field_name]["count"] += 1
            if isinstance(field_value, dict):
                value_str = json.dumps(field_value)
            else:
                value_str = str(field_value)
            field_usage[field_name]["max_length"] = max(
                field_usage[field_name]["max_length"], len(value_str)
            )
            field_usage[field_name]["types"].add(type(field_value).__name__)

# Fields that exceed VARCHAR(255) — cannot fit in custom fields
for field, stats in sorted(field_usage.items(), key=lambda x: -x[1]["max_length"]):
    will_truncate = stats["max_length"] > 255
    print(f"{field}: used {stats['count']}x, max {stats['max_length']} chars, "
          f"truncates={'YES' if will_truncate else 'no'}")
```

This gives you a count of fields that exceed Textpattern's 255-character custom field limit, fields with complex types (nested objects, arrays) that cannot survive flattening, and fields used infrequently enough to drop without editorial impact. Running this audit before stakeholder sign-off converts "we'll figure it out" into a documented, accepted data loss table.

### Exporting Images Separately

Since the Batch Export API does not include images and assets, you have three options:

1. **External DAM:** If Bloomreach references images from a DAM (Cloudinary, Bynder, etc.), those URLs remain valid post-migration. Update references in content bodies accordingly.
2. **Delivery API scraping:** Fetch published content via the Delivery API and parse image URLs from the responses. Resource URLs can point to original files or image variants.
3. **Manual/JCR export:** For brXM, images are stored in the JCR repository. Use WebDAV access or the Hippo Content EXIM tooling to bulk-export binaries.

Be aware of Bloomreach Content's media constraints: images are public by default with a 4MB and 1920×1280 upload limit; assets are public by default with a 10MB limit. Some source originals may already be constrained or transformed before export. ([documentation.bloomreach.com](https://documentation.bloomreach.com/content/docs/images-and-assets))

### Handling Rich Text References

Bloomreach stores rich text as HTML, but embedded images and internal links are often represented as reference nodes (e.g., `hippogallery:imageset` or internal UUIDs in brXM; structured reference objects in Content SaaS) rather than standard `<img>` or `<a>` tags. During extraction, your script must parse the JSON, locate these internal references, and resolve them against the linked objects in the API response. If you skip this step, your Textpattern articles will contain broken UUID strings instead of actual images and links. This is the most common source of body content corruption in this migration path.

## Transforming Bloomreach Content for Textpattern's Schema

### Document-to-Article Mapping

Map Bloomreach document types to Textpattern **Sections**. Textpattern relies on Sections to define URL structures and page templates. If your Bloomreach setup has multiple channels (e.g., `en-us` and `fr-fr`), map each to a Textpattern section — or use separate Textpattern instances for true multi-site.

Use Textpattern's two available Category slots (`Category1`, `Category2`) for secondary classification and the `Keywords` field for additional tagging.

Here's a Python transformation function:

```python
def transform_bloomreach_doc(doc, section_map, category_map):
    """Transform a Bloomreach NDJSON document to a Textpattern article dict."""
    fields = doc.get("data", {})
    doc_type = doc.get("type", "")
    
    # Map Bloomreach document type to Textpattern section
    section = section_map.get(doc_type, "articles")
    
    # Extract rich text body — Bloomreach stores HTML in rich text fields
    body_html = fields.get("body", {}).get("value", "")
    
    # Handle categories (max 2 in Textpattern)
    categories = fields.get("categories", [])
    cat1 = category_map.get(categories[0], "") if len(categories) > 0 else ""
    cat2 = category_map.get(categories[1], "") if len(categories) > 1 else ""
    
    # Warn on data loss
    if len(categories) > 2:
        print(f"WARNING: {fields.get('title', 'unknown')} has {len(categories)} categories; "
              f"only first 2 will be migrated")
    
    return {
        "Title": fields.get("title", "")[:255],
        "Body": body_html,
        "Body_html": body_html,  # must match Body when textile_body=0
        "Excerpt": fields.get("introduction", {}).get("value", "")[:65535],
        "Excerpt_html": fields.get("introduction", {}).get("value", "")[:65535],
        "textile_body": 0,  # 0 = raw HTML, no processing
        "textile_excerpt": 0,
        "Section": section,
        "Category1": cat1[:64],
        "Category2": cat2[:64],
        "Status": 4,  # live
        "Posted": fields.get("publishDate", ""),
        "url_title": generate_url_slug(fields.get("title", "")),
        "Keywords": ",".join(fields.get("tags", []))[:255],
        "AuthorID": fields.get("author", "admin"),
    }
```

### Flattening Multiple Content Fields

If a Bloomreach document contains multiple rich text fields (e.g., `introText`, `mainBody`, `conclusion`), concatenate them into a single HTML string for Textpattern's `Body` column. Bloomreach's component-based page composition, personalization variants, and dynamic content injection do not survive — you are migrating raw text, images, and metadata only.

> [!WARNING]
> **The core constraint:** You cannot migrate Bloomreach's presentation layer. Component layouts, personalization variants, and dynamic content injection are permanently lost. Everything flattens into a single body field.

### The Textile Decision

Textpattern natively supports **Textile**, a lightweight markup language. You have three options for body content:

| Option | `textile_body` value | When to use |
|---|---|---|
| Store as raw HTML | 0 | Default choice. Bloomreach rich text is already HTML; set `Body` and `Body_html` to the same value. Zero conversion risk. |
| Convert to Textile | 1 | Only if your editorial team specifically wants Textile authoring going forward. Use Pandoc for conversion; audit tables, nested lists, and footnotes manually — these do not round-trip cleanly. |
| Store both | 0 (serve HTML) | Put original HTML in `Body_html`, a Textile approximation in `Body`. Protects against lossy conversion while enabling future Textile editing. |

**Decision rule:** Use raw HTML (option 1) unless your editorial team has explicitly requested Textile. Converting to Textile adds engineering risk and requires manual QA on every complex HTML structure without providing any end-user benefit unless editors plan to write in Textile going forward.

### Custom Field Mapping

This is where data loss becomes quantifiable. A Bloomreach document type with 15 fields cannot fit into Textpattern's 10 `custom_1` through `custom_10` fields, each capped at 255 characters.

**Mapping strategy (in priority order):**

1. Run the pre-migration audit script (see above) to identify which fields are actually populated and how large their values are.
2. Map the top 10 most editorially active fields to `custom_1` through `custom_10`. Fields used in fewer than 5% of documents are candidates for dropping.
3. For values that exceed 255 characters, either truncate with documented business acceptance, or serialize into a JSON blob embedded in the article body as a data attribute — not elegant, but recoverable.
4. For production deployments, evaluate **glz_custom_fields** (Textpattern plugin, maintained for 4.8.x as of this writing). It extends custom fields with unlimited fields of types including textarea, date, radio, checkbox, and select. This plugin introduces a dependency you must track for upgrades.
5. Accept that compound fields (nested objects, arrays of references) cannot be represented in Textpattern's schema and must be flattened or discarded. Document every such decision in a migration log.

### Channel and Page Mapping

Bloomreach's concept of **channels** and **pages** (layout entities with component hierarchies) has no direct Textpattern equivalent:

- **Channels → Sections:** Map each Bloomreach channel to a Textpattern section. Textpattern uses a lateral structure rather than a hierarchical one (a constraint we explore further in our [Textpattern to WordPress migration guide](https://clonepartner.com/blog/blog/textpattern-to-wordpress-migration-a-technical-guide/)).
- **Pages → Page templates:** Bloomreach pages with component layouts map to Textpattern's `txp_page` templates and `txp_form` forms. This is a manual rebuild — there is no way to automate component-to-tag conversion.
- **Menus → Links or hardcoded navigation:** Bloomreach menus don't export to a portable format. Rebuild navigation using Textpattern's `txp_link` table or section-based navigation tags.
- **Resource bundles → Custom strategy:** Bloomreach resource bundles (i18n key-value pairs) rarely belong in articles. Textpattern's core publishing model has no equivalent for message-level localization. The `txp_lang` table stores interface strings only — it is not a content translation system.

## Loading Data Into Textpattern

### Preparing the Database

Before inserting articles, create the required relational data in this order. Loading articles first causes orphaned records and silent failures.

1. **Users:** Create accounts in `txp_users`. The `name` column (login ID) is what maps to `AuthorID` in the articles table — not `RealName`, not email.
2. **Sections:** Create sections in `txp_section`.
3. **Categories:** Create categories in `txp_category`. Textpattern uses the modified preorder tree traversal (MPTT) algorithm for category hierarchy — you must set `lft` and `rgt` values correctly or queries against the category tree will return wrong results silently.

```sql
-- Create sections
INSERT INTO txp_section (name, title, page, css, skin, in_rss, on_frontpage, searchable)
VALUES ('blog', 'Blog', 'default', 'default', 'default', 1, 1, 1);

-- Create categories (type must be 'article', 'image', 'file', or 'link')
INSERT INTO txp_category (name, type, parent, lft, rgt, title)
VALUES ('news', 'article', 'root', 2, 3, 'News');
```

After inserting all categories, run the Textpattern Diagnostics panel — it will detect and offer to repair MPTT tree inconsistencies.

### Direct MySQL INSERT (Recommended)

The most reliable approach. Generate SQL statements from your transformed data:

```sql
INSERT INTO textpattern (
  Title, Body, Body_html, Excerpt, Excerpt_html,
  Section, Category1, Category2, Status,
  textile_body, textile_excerpt, Posted, LastMod,
  url_title, AuthorID, uid, feed_time, Keywords
) VALUES (
  'My Article Title',
  '<p>Article body HTML</p>',
  '<p>Article body HTML</p>',
  'Short excerpt',
  '<p>Short excerpt</p>',
  'blog', 'news', '',
  4,  -- live
  0,  -- raw HTML
  0,  -- raw HTML
  '2025-03-15 10:00:00',
  '2025-03-15 10:00:00',
  'my-article-title',
  'admin',
  MD5(CONCAT(RAND(), NOW())),  -- generates unique ID; not an official Textpattern method
  '2025-03-15',
  'bloomreach,migration'
);
```

Note: `MD5(CONCAT(RAND(), NOW()))` for `uid` generation is a common workaround but is not documented as an official Textpattern recommendation. Textpattern's own code generates UIDs using PHP's `md5(uniqid(rand(), true))`. Either approach produces a sufficiently unique value for feed purposes; the key requirement is that `uid` is unique per article and non-null.

> [!CAUTION]
> **Critical database quirks:**
> - When `textile_body=0`, populate both `Body` and `Body_html` with the same HTML payload. Leaving `Body_html` blank causes the article to render empty on the frontend.
> - `uid` and `feed_time` are required for RSS/Atom feeds. Missing values cause silent feed breakage — not a PHP error, not a warning, just missing items.
> - `AuthorID` must exactly match a `name` value in `txp_users`. Articles with non-existent author references appear in the database but may break admin views and author-filtered queries.

> [!WARNING]
> **Category tree integrity:** If you insert categories manually, set `lft` and `rgt` values correctly. After inserting all categories, run the Textpattern admin Diagnostics panel to detect and repair MPTT tree inconsistencies before loading articles.

## Image and File Migration

Textpattern stores image and file **metadata** in `txp_image` and `txp_file` tables, but the actual binaries live on disk at specific paths that are tied to the auto-increment database ID:

- Images: `/images/{id}.{ext}` (thumbnails: `/images/{id}t.{ext}`)
- Files: `/files/{filename}`

This ID-to-filename coupling is not optional. If the file is not at `/images/{id}.jpg`, Textpattern image tags fail silently.

The migration flow for every image:

1. **Download** the binary from the Bloomreach CDN URL or DAM.
2. **Insert metadata** into `txp_image` to generate the auto-increment ID:

```sql
INSERT INTO txp_image (
    name, category, author, alt, caption, date, ext, w, h
) VALUES (
    'original_filename.jpg',
    'general',
    'admin',
    'Alt text from Bloomreach',
    'Caption from Bloomreach',
    '2025-03-15 10:00:00',
    '.jpg',
    800,
    600
);
-- Note the generated ID: LAST_INSERT_ID()
```

3. **Rename and copy** the file to `/images/{id}.{ext}` using the assigned database ID (e.g., ID `42` → `/images/42.jpg`).
4. **Update article body HTML** to replace Bloomreach image URLs with Textpattern paths (`/images/42.jpg`) or Textpattern image tags (`<txp:image id="42" />`).

For guidance on handling media across platforms, see [How to Migrate Images, Attachments & Embeds Without Broken Links](https://clonepartner.com/blog/blog/how-to-migrate-images-attachments-embeds-without-broken-links/).

## URL Structure and SEO Redirect Map

Bloomreach URLs are typically channel-routed (e.g., `/blog/2025/my-post`) and defined by the site's routing configuration. Textpattern uses section-based permalinks with several possible modes:

- `/section/id/title` (default)
- `/section/title`
- `/year/month/day/title`
- `/title-only`

The URL structures will change. Every Bloomreach URL with organic search value needs a 301 redirect to its Textpattern equivalent.

**Redirect generation process:**
1. Extract the full URL path from Bloomreach's routing configuration or sitemap.
2. Determine the new Textpattern URL based on your chosen permlink mode.
3. Generate redirect rules as part of your transformation script — not manually, not after cutover.

```nginx
# Nginx rewrite
rewrite ^/news/press-releases/2023/q4-earnings/?$ /press/q4-earnings permanent;
```

```apache
# Apache .htaccess
Redirect 301 /blog/2025/03/enterprise-content-strategy /blog/enterprise-content-strategy
```

For sites with thousands of URLs, avoid storing all redirects in `.htaccess` — the file is read on every request and degrades performance at scale. Use a RewriteMap in Apache or a key-value store in Nginx to handle redirects at the server level. Every unmapped URL with search value is a permanent ranking loss.

## What You Permanently Lose

| Bloomreach capability | Textpattern equivalent | Impact |
|---|---|---|
| Custom document types with typed fields | Flat article model + 10 custom fields (255 chars each) | Major data loss for complex types |
| Rich text with embedded content references | HTML or Textile body text | Internal doc links become broken strings |
| Multi-channel delivery | Single site per install | Must duplicate for multi-site |
| Personalization / targeting | None | Complete feature loss |
| Headless API (Delivery API) | No content API | Frontend apps lose data source |
| Workflow (draft → review → publish) | Basic status flags (1=draft, 3=pending, 4=live) | Simplified editorial process |
| Compound / nested fields | VARCHAR(255) custom fields | Cannot represent complex data |
| Resource bundles (i18n) | `txp_lang` interface strings only | Not a content translation system |
| Image/asset management via API | File-based with DB metadata, ID-coupled filenames | Manual binary migration required |
| Component-based page layouts | Tag-based templates with 64KB limit | Full rebuild required |
| More than 2 taxonomy terms per content item | `Category1`, `Category2`, `Keywords` varchar | Taxonomy collapses significantly |

## Common Failure Modes

**1. Body content with broken references.** Bloomreach rich text contains inline content references, embedded components, and dynamic content blocks that render as broken markup or raw UUID strings in Textpattern. Audit and clean HTML before inserting — after insertion, finding and fixing these at scale requires touching every affected article row.

**2. Category tree corruption.** Inserting categories without correct `lft`/`rgt` MPTT values breaks Textpattern's tree traversal. Symptoms: categories appear in admin but return no articles; category parent/child relationships display incorrectly. Fix via Diagnostics panel post-import.

**3. Author mismatch.** `AuthorID` must match a `name` in `txp_users`. Articles with non-existent author references appear in the database but become orphaned in admin views and break author-filtered template tags. Create all user accounts before loading articles.

**4. Missing `uid` and `feed_time`.** These columns are required for RSS/Atom feeds. Omitting them causes silent feed breakage — items simply don't appear in feeds without any error output. Generate `uid` with a unique hash function and set `feed_time` to the article creation date on every inserted row.

**5. `Body_html` left blank.** When `textile_body=0`, Textpattern serves `Body_html` directly. Leaving it null or empty causes articles to render blank on the frontend. This fails silently — no error, just empty content blocks.

**6. Image path references not rewritten.** Bloomreach image URLs won't resolve after migration. Run a find-and-replace pass across all `Body_html` and `Excerpt_html` columns to rewrite paths to `/images/{id}.{ext}` format before going live.

**7. Loading to production before staging validation.** Direct database access removes guardrails. Always load into staging first. Verify article counts, media presence, body rendering, category tree integrity, redirect coverage, and RSS feed output before promoting or replaying the import to production.

## When This Migration Does Not Make Sense

This migration works for teams intentionally downsizing from enterprise CMS complexity to a minimal publishing tool. It does **not** make sense if any of the following are true:

- **You deliver content to mobile apps or SPAs via API.** Textpattern has no content delivery API. This is a hard stop, not a limitation to work around.
- **Your content model uses more than 10 distinct metadata fields per document type.** The glz_custom_fields plugin removes this ceiling, but it's a plugin dependency with upgrade risk.
- **You need multi-language content management.** Textpattern's i18n covers interface strings, not content translation workflows. There is no built-in equivalent to Bloomreach's channel-level locale management.
- **Your editorial team requires approval workflows.** Textpattern's draft/pending/live status flags are a significant downgrade from Bloomreach's project-based workflow with reviewer assignments.
- **You manage more than one site from a single CMS installation.** Textpattern is one-site-per-installation. Multi-site requires separate installs with separate databases.
- **You need more than two first-class taxonomy terms per content item.** The two-category limit is a schema constraint, not a configuration option.

If you are moving away from Bloomreach but need structured content with API delivery, choose a different target (such as an enterprise DXP like [Sitecore](https://clonepartner.com/blog/blog/duda-to-sitecore-migration-a-technical-guide/)). Textpattern is the correct choice only when intentional simplification — not feature parity — is the explicit goal.

## Migration Checklist

1. **Identify the source Bloomreach product.** Confirm Content SaaS or brXM — the export surfaces differ and the wrong choice means rewriting your extractor.
2. **Run the pre-migration field audit.** Count document types, enumerate all fields, measure max field lengths, identify compound types. Produce a documented data loss table before stakeholder sign-off.
3. **Map document types to sections.** Each Bloomreach document type or channel becomes a Textpattern section.
4. **Export via Batch Export API** (SaaS) or Content REST API (brXM). Verify document count against Delivery API totals.
5. **Export images separately.** DAM URLs, Delivery API scraping, or JCR WebDAV export — document which method and verify binary count.
6. **Build transformation scripts.** Parse NDJSON/JSON, map fields to Textpattern columns, handle HTML cleanup, resolve internal content references, and enforce truncation at schema limits.
7. **Set up Textpattern staging.** Fresh install. Create users, sections, and categories (with correct `lft`/`rgt` MPTT values) before loading articles.
8. **Load articles** via SQL INSERT. Validate row count matches source document count.
9. **Migrate image binaries** to `/images/{id}.{ext}` directory and insert `txp_image` metadata. Verify ID-to-filename coupling.
10. **Rewrite image references** in `Body_html` and `Excerpt_html` to Textpattern paths.
11. **Generate and deploy 301 redirects.** Every Bloomreach URL with search value needs a mapped redirect rule.
12. **Run delta sync** using `modifiedAfter` (SaaS) to capture content changes made after the base export.
13. **Run Textpattern Diagnostics panel.** Check for MPTT tree corruption, missing author references, and other detected issues.
14. **Test every content type** on the frontend: article rendering, category filtering, author pages, RSS feeds, image display, and redirect behavior.

For a structured approach to zero-downtime migration planning, see [The Ultimate Knowledge Base Migration Checklist](https://clonepartner.com/blog/blog/the-ultimate-knowledge-base-migration-checklist-a-zero-downtime-plan/).

## The Practical Path Forward

A Bloomreach-to-Textpattern migration succeeds when you separate **content extraction**, **media re-hosting**, **template rebuild**, and **URL cutover** into distinct workstreams with explicit acceptance criteria. Treating it as a vendor export followed by a weekend import is the most reliable way to arrive at production with broken body content, missing images, and orphaned articles.

The pre-migration field audit is the highest-leverage investment. It converts vague data loss risk into a documented, stakeholder-accepted table of decisions — which fields survive, which get truncated, which get dropped. That table is the contract between engineering and editorial before a single INSERT runs.

This is an exercise in intentional simplification. By stripping away the headless architecture, you regain direct database-level control, lower your hosting footprint, and eliminate the need for a dedicated frontend engineering team. The migration is technically tractable. What typically goes wrong is not the SQL — it's the undiscovered content model complexity that surfaces during transformation.

> Need help migrating from Bloomreach to Textpattern? Our engineers build custom extraction and loading scripts so you don't lose data or rankings. Book a free 30-minute technical consultation.
>
> [Talk to us](https://cal.com/clonepartner/meet?duration=30)

## Frequently asked questions

### Can you migrate Bloomreach content to Textpattern automatically?

No. There is no native migration path, connector, or import wizard. Bloomreach exports content as NDJSON via its Batch Export API, but Textpattern has no write API. You must build custom scripts to transform Bloomreach's structured documents into SQL INSERT statements for Textpattern's MySQL tables.

### Does Bloomreach's batch export API include images?

No. The Bloomreach Content Batch Export API does not support images and assets. If you store media in Bloomreach's built-in repository rather than an external DAM, you must export images separately via Delivery API scraping, JCR WebDAV access, or manual download.

### How many custom fields does Textpattern support?

Textpattern supports 10 custom fields by default (custom_1 through custom_10), each limited to VARCHAR(255). If your Bloomreach document types have more than 10 metadata fields, you need the glz_custom_fields plugin or must serialize overflow data into the article body.

### Should I convert Bloomreach HTML to Textile for Textpattern?

For most migrations, no. Store Bloomreach's HTML directly in Textpattern with textile_body set to 0 (raw mode). Converting to Textile via Pandoc introduces risk — complex tables, nested lists, and embedded content may not convert cleanly. Only convert if your team specifically wants Textile authoring going forward.

### What data is permanently lost in a Bloomreach to Textpattern migration?

You permanently lose multi-channel delivery, content personalization, headless API access, compound/nested field structures, project-based editorial workflows, content reference linking between documents, and resource bundle i18n support. Textpattern's flat model cannot represent Bloomreach's structured content architecture.
