---
title: "TYPO3 to Blogger Migration: A Technical Guide"
slug: typo3-to-blogger-migration-a-technical-guide
date: 2026-09-01
author: Abdul Aleem
categories: [TYPO3, Blogger, Migration Guide]
excerpt: "A technical guide to migrating from TYPO3 to Blogger. Covers database extraction, content element merging, Atom import format, API limits, and URL redirects."
tldr: "TYPO3 to Blogger migration requires custom ETL: extract via SQL, merge content elements into flat HTML, build Blogger's Atom XML import file, and handle image re-hosting and URL redirects manually."
canonical: https://clonepartner.com/blog/typo3-to-blogger-migration-a-technical-guide
---

# TYPO3 to Blogger Migration: A Technical Guide


# TYPO3 to Blogger Migration: A Technical Guide

A **TYPO3 to Blogger migration** moves content from a self-hosted, enterprise PHP/MySQL CMS into Google's free hosted blogging platform. No automated tool or plugin handles this path. Every migration requires custom extraction from TYPO3's relational database, transformation of content elements into flat HTML, and loading into Blogger's Atom-based import format or via the Blogger API v3.

The architectural mismatch is severe. TYPO3 manages content via a hierarchical page tree, modular content elements typed by `CType`, and a centralized File Abstraction Layer (FAL). Blogger stores content as flat posts or static pages, accepts data exclusively via Atom XML (conforming to [RFC 4287](https://www.rfc-editor.org/rfc/rfc4287)) or the [Blogger API v3](https://developers.google.com/blogger/docs/3.0/reference), offers no database access, and caps you at 20 labels per post, 5,000 labels per blog, and **500 posts per blog** (a hard platform limit that is frequently undocumented but consistently enforced).

Teams make this move to eliminate hosting costs, shed PHP/MySQL infrastructure, or consolidate a small publishing operation onto a zero-maintenance Google-hosted platform. The trade-off is permanent: you lose page hierarchy, custom content types, backend user roles, extension functionality, multi-language support, server-side rendering control, and any content model more complex than "title + HTML body + labels."

> [!WARNING]
> **No native migration path exists.** TYPO3's `EXT:impexp` exports proprietary T3D/XML files that Blogger cannot read. Blogger's import accepts only its own Atom feed format. Every migration requires custom ETL scripts.

**Estimated effort by site complexity:**

| Site profile | Estimated effort | Real-world example |
|---|---|---|
| <50 pages, text-only content, no extensions | 1–2 days | 40-page company blog, plain `tt_content` text elements, no FAL images |
| 50–300 pages, images via FAL, basic categories | 3–5 days | 180-page TYPO3 news site with 400 FAL images: 1 day SQL/transform scripting, 2 days image re-hosting and URL rewriting, 1 day QA and redirect mapping |
| 300+ pages, custom content elements, FlexForm data, strict URL redirect requirements | 1–2 weeks | Sites using EXT:news + EXT:solr + custom CTypes with FlexForm configuration |

These estimates assume one engineer with SQL/Python proficiency, a TYPO3 staging instance with database access, and a Blogger account ready to receive content. Each variable — multi-language content, custom `tx_*` extension tables, complex page-tree hierarchies — adds 0.5–1 day. Sites exceeding 500 posts cannot be fully migrated to a single Blogger blog due to the platform's hard post cap.

## How TYPO3 and Blogger Store Content Differently

**TYPO3** stores all content in a relational database (MySQL/MariaDB or PostgreSQL) organized around a page-tree hierarchy. Every page lives in the `pages` table with `uid`/`pid` relationships forming a parent-child tree. Content elements — text blocks, images, plugins — sit in the `tt_content` table, linked to pages via the `pid` field. A single TYPO3 page often contains *multiple* `tt_content` records, ordered by a `sorting` column and placed into layout columns via `colPos`. The `bodytext` field in `tt_content` holds the raw text/HTML content for elements of type `text`, `textpic`, `textmedia`, and similar CTypes.

Media is managed through the **File Abstraction Layer (FAL)**. Files are tracked in `sys_file`, metadata in `sys_file_metadata`, and usage references in `sys_file_reference`. Images attached to a content element aren't embedded inline — they're referenced through this relational layer.

**Blogger** has a radically simpler model. Content exists as **posts** or **static pages**, each with a title, HTML body, labels (tags), a publication date, and an author. There is no page hierarchy, no content element model, and no relational media layer. Images are either inline `<img>` tags referencing URLs or uploaded to Google's servers during manual editing.

| Concept | TYPO3 | Blogger |
|---|---|---|
| Content structure | Hierarchical page tree (`pages` + `tt_content`) | Flat list of posts and static pages |
| Content model | Multiple content elements per page, typed by `CType` | Single HTML body per post |
| Media handling | FAL (`sys_file`, `sys_file_reference`) | Inline `<img>` tags, Google-hosted |
| Categories | `sys_category` with parent-child nesting | Flat labels, max 20 per post, 5,000 per blog |
| Post cap | No limit | 500 posts per blog (hard limit) |
| URLs | Configurable via route enhancers / RealURL | Date-based (`/YYYY/MM/title.html`) |
| Templates | TypoScript + Fluid | Blogger XML theme templates |
| API access | No built-in REST API (extensions like `EXT:headless` required) | [Blogger API v3](https://developers.google.com/blogger/docs/3.0/reference) (REST/JSON) |
| Database access | Direct MySQL/MariaDB/PostgreSQL | None |

To bridge this gap, your migration script must query the `pages` table to identify articles, join `tt_content` to retrieve all content elements per page, concatenate those elements in the correct order to form a single HTML body, and wrap the result in Blogger's Atom XML format.

## Step 1: Extract Content from TYPO3

Direct SQL extraction from TYPO3's database is the fastest path to migration-ready data. TYPO3's built-in `EXT:impexp` generates proprietary T3D/XML files designed only for other TYPO3 instances — useless for Blogger. For a detailed breakdown of every export method and its limitations, see our [TYPO3 export guide](https://clonepartner.com/blog/blog/how-to-export-data-from-typo3-methods-api-limits-portability).

### Query pages and content elements

Join `pages` to `tt_content` to get every visible content element associated with each page. Set the MySQL connection character set to `utf8mb4` before running any queries — TYPO3 databases may contain mixed Latin-1/UTF-8 content that will produce malformed XML if not normalized at the connection level:

```sql
SET NAMES utf8mb4;

SELECT
  p.uid AS page_id,
  p.title AS page_title,
  p.slug AS page_slug,
  p.crdate AS created,
  p.tstamp AS modified,
  p.doktype,
  tc.uid AS content_uid,
  tc.header AS content_header,
  tc.bodytext,
  tc.CType,
  tc.sorting,
  tc.colPos
FROM pages p
JOIN tt_content tc ON tc.pid = p.uid
WHERE p.deleted = 0
  AND p.hidden = 0
  AND tc.deleted = 0
  AND tc.hidden = 0
  AND p.doktype = 1
ORDER BY p.uid, tc.sorting;
```

`doktype = 1` filters to standard content pages. TYPO3 uses other `doktype` values for folders (254), shortcuts (4), links (3), and backend modules (182). Only `doktype = 1` pages carry displayable content that maps to Blogger posts.

### Extract media references

Images and files attached to content elements live in the FAL tables:

```sql
SELECT
  sfr.uid_foreign AS content_uid,
  sfr.tablenames,
  sfr.fieldname,
  sf.identifier AS file_path,
  sf.name AS file_name,
  sfm.title AS media_title,
  sfm.description AS media_alt
FROM sys_file_reference sfr
JOIN sys_file sf ON sf.uid = sfr.uid_local
LEFT JOIN sys_file_metadata sfm ON sfm.file = sf.uid
WHERE sfr.tablenames = 'tt_content'
  AND sfr.deleted = 0
  AND sfr.hidden = 0
ORDER BY sfr.uid_foreign, sfr.sorting_foreign;
```

The `identifier` field gives you the relative path within the storage (typically under `fileadmin/`). You'll need to construct full URLs or copy files to a publicly accessible location before referencing them in Blogger posts.

### Extract categories

TYPO3's category system uses `sys_category` (with parent-child relationships) and `sys_category_record_mm` for record associations:

```sql
SELECT
  mm.uid_foreign AS record_uid,
  mm.tablenames,
  sc.title AS category_name
FROM sys_category_record_mm mm
JOIN sys_category sc ON sc.uid = mm.uid_local
WHERE mm.tablenames = 'tt_content'
  AND sc.deleted = 0
  AND sc.hidden = 0;
```

> [!NOTE]
> **Page-level vs. element-level categories:** TYPO3 categories are typically assigned to `tt_content` records, not to `pages`. When aggregating content elements into a single Blogger post (one post per page), you'll need to merge categories from all content elements on that page and deduplicate. Blogger enforces a hard limit of 20 labels per post.

## Step 2: Extract EXT:news Records

If your TYPO3 site uses the **EXT:news** extension — the most common way TYPO3 sites publish blog-like content — the articles live in `tx_news_domain_model_news`, not in `pages` or `tt_content`. This is a separate extraction path with its own schema.

### Query the news table

```sql
SET NAMES utf8mb4;

SELECT
  n.uid,
  n.title,
  n.bodytext,
  n.teaser,
  n.datetime AS published,
  n.tstamp AS modified,
  n.path_segment AS slug,
  n.deleted,
  n.hidden
FROM tx_news_domain_model_news n
WHERE n.deleted = 0
  AND n.hidden = 0
ORDER BY n.datetime DESC;
```

### Extract EXT:news categories

EXT:news uses its own category table (`tx_news_domain_model_category`) joined through `tx_news_domain_model_news_tag_mm` for tags and `sys_category_record_mm` for system categories. The most reliable path for category mapping:

```sql
SELECT
  mm.uid_foreign AS news_uid,
  sc.title AS category_name
FROM sys_category_record_mm mm
JOIN sys_category sc ON sc.uid = mm.uid_local
WHERE mm.tablenames = 'tx_news_domain_model_news'
  AND sc.deleted = 0;
```

### Map EXT:news fields to Blogger

| EXT:news field | Blogger field | Notes |
|---|---|---|
| `title` | Post title | Direct mapping |
| `bodytext` | Post HTML body | May contain RTE HTML; resolve `t3://` links |
| `teaser` | Prepend to body as `<p class="teaser">` | Blogger has no teaser/excerpt field |
| `datetime` | `published` (ISO 8601) | Unix timestamp — convert to ISO 8601 |
| `path_segment` | Used to construct Blogger URL | Blogger generates its own URL; use for redirect map |
| `categories` (via MM) | Labels | Deduplicate; truncate to 20 |

EXT:news image attachments also go through FAL — use the same `sys_file_reference` query above, substituting `tablenames = 'tx_news_domain_model_news'`.

## Step 3: Transform Content for Blogger

This is where the real engineering work happens. TYPO3 stores content as discrete elements; Blogger expects a single HTML body per post.

### Merge multiple content elements into one post body

A TYPO3 page with three `tt_content` records (a header, a text block, and an image) must become one HTML string. Process elements in `sorting` order, filter by `colPos` (typically `colPos = 0` for the main content column), and concatenate:

```python
def merge_content_elements(elements):
    """Merge ordered tt_content records into a single HTML body."""
    html_parts = []
    for el in sorted(elements, key=lambda x: x['sorting']):
        if el['CType'] in ('text', 'textpic', 'textmedia'):
            if el.get('content_header'):
                html_parts.append(f"<h2>{el['content_header']}</h2>")
            if el.get('bodytext'):
                html_parts.append(el['bodytext'])
        elif el['CType'] == 'header':
            if el.get('content_header'):
                html_parts.append(f"<h2>{el['content_header']}</h2>")
        elif el['CType'] == 'image':
            # Handle via FAL reference lookup — see image rewriting section
            pass
        # Skip CTypes like 'list', 'shortcut', 'div', 'html'
        # that reference plugins or structural layout
    return '\n'.join(html_parts)
```

Before merging, audit your content element types. Run a `GROUP BY CType` query on `tt_content` to identify all custom content elements that require special parsing:

```sql
SELECT CType, COUNT(*) AS cnt FROM tt_content WHERE deleted = 0 GROUP BY CType ORDER BY cnt DESC;
```

### Handle character encoding

TYPO3 databases — especially those upgraded from versions prior to 6.x — may contain columns in Latin-1 encoding despite the database declaring UTF-8. Setting `SET NAMES utf8mb4` at the MySQL connection level is necessary but not sufficient if the underlying column data is mismatched.

In your Python extraction script, force UTF-8 at the connector level and sanitize strings before writing to XML:

```python
import mysql.connector

conn = mysql.connector.connect(
    host='localhost',
    database='typo3db',
    user='user',
    password='password',
    charset='utf8mb4',
    use_unicode=True
)

def sanitize_for_xml(text):
    """Remove or replace characters that break XML generation."""
    if not text:
        return ''
    # Replace NULL bytes and non-UTF-8 sequences
    text = text.encode('utf-8', errors='replace').decode('utf-8')
    # Strip control characters except tab, newline, carriage return
    import re
    text = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '', text)
    return text
```

Unhandled encoding issues produce the same generic import failure as malformed XML — Blogger's error messages do not distinguish between the two.

### Handle RTE content

TYPO3's Rich Text Editor stores HTML in `bodytext`, but the format depends on your TYPO3 version and RTE configuration:

- **TYPO3 < 8.x with rtehtmlarea:** May store `<link>` tags instead of standard `<a>` tags, and internal links use `t3://page?uid=123` syntax
- **TYPO3 8+ with CKEditor:** Stored HTML is closer to standard, but `t3://` link syntax is still used for internal links

Resolve all internal link types before import:

- **`t3://page?uid=123`** → Look up the target page's slug or future Blogger URL and replace with absolute URL
- **`t3://file?uid=456`** → Resolve to the file's public URL via FAL
- **`<link 123>`** (legacy rtehtmlarea format) → Same treatment as `t3://page` links

Scan all `bodytext` content for `t3://` prefixes and validate every target UID exists in your export before proceeding. References to deleted or hidden pages (page 999 may have been soft-deleted with `deleted = 1`) become dead links if not caught here.

### Resolve and re-host images

Blogger's XML import does not sideload images. If you include an `<img>` tag pointing to your old TYPO3 server, Blogger will render that URL as-is. Once you decommission the TYPO3 server, every image breaks.

To solve this, decouple the media before importing:

1. **Host images externally** — Upload TYPO3's `fileadmin/` assets to a CDN, cloud storage bucket (GCS, S3), or keep them on the old domain temporarily
2. **Rewrite `<img>` tags** — In your migration script, parse the concatenated `bodytext` and replace all relative TYPO3 image paths (e.g., `src="https://clonepartner.com/blog/fileadmin/user_upload/image.jpg"`) with new absolute URLs (e.g., `src="https://cdn.yourdomain.com/image.jpg"`)
3. **Set alt text** — Pull `description` from `sys_file_metadata` and inject as `alt` attributes

> [!WARNING]
> **Image hosting is your responsibility.** Blogger provides no API endpoint for bulk image uploads and does not ingest external images during Atom import. Relying on an external CDN is the standard practice for programmatic Blogger migrations. Plan image hosting *before* you start the migration.

## Step 4: Build the Blogger Import File

Blogger's import accepts an **Atom XML feed** conforming to [RFC 4287 (Atom Syndication Format)](https://www.rfc-editor.org/rfc/rfc4287). The format combines posts, pages, and comments into a single Atom document, differentiated by `<category>` elements with Blogger-specific scheme URIs.

The minimal structure for a working import file:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"
      xmlns:thr="http://purl.org/syndication/thread/1.0">
  <id>tag:blogger.com,1999:blog-YOUR_BLOG_ID.archive</id>
  <updated>2026-01-01T00:00:00.000+00:00</updated>
  <title type="text">Your Blog Title</title>
  <generator>Blogger</generator>
  <author>
    <name>Author Name</name>
    <email>noreply@blogger.com</email>
  </author>

  <!-- Repeat for each post -->
  <entry>
    <id>tag:blogger.com,1999:blog-YOUR_BLOG_ID.post-UNIQUE_POST_ID</id>
    <published>2025-06-15T10:00:00.000+00:00</published>
    <updated>2025-06-15T10:00:00.000+00:00</updated>
    <category scheme="http://schemas.google.com/g/2005#kind"
              term="http://schemas.google.com/blogger/2008/kind#post"/>
    <category scheme="http://www.blogger.com/atom/ns#" term="LabelName"/>
    <title type="text">Post Title</title>
    <content type="html">HTML content, XML-escaped</content>
    <author>
      <name>Author Name</name>
      <email>noreply@blogger.com</email>
    </author>
  </entry>
</feed>
```

### Requirements that trip up most migrations

- **The `<id>` tag must follow Blogger's tag-URI format** (`tag:blogger.com,1999:blog-BLOGID.post-POSTID`). Random IDs work, but they must be unique across the file.
- **The `<category>` element with `#kind` scheme is mandatory.** Use `kind#post` for posts and `kind#page` for static pages. Omitting this causes the import to silently skip entries.
- **HTML in `<content>` must be XML-escaped.** Unescaped `<`, `>`, or `&` characters will break the XML parser and the entire import fails with a generic, unhelpful error message. Use `xml.sax.saxutils.escape()` in Python — do not write your own escaping.
- **Dates must be in ISO 8601 format** with timezone offset. If you do not explicitly set the `published` date, Blogger assigns the current timestamp, destroying your historical timeline.

### XML import constraints

Blogger's web UI has strict, often undocumented limits on XML file sizes. Imports larger than **50 MB** frequently time out or fail silently. If you are migrating hundreds of TYPO3 pages, write your transformation script to chunk the output into multiple XML files. Import them sequentially.

Blogger also enforces a daily publishing limit of approximately **50–60 posts**. This applies to both the UI file import (with auto-publish enabled) and the API. A 500-post migration requires staging imports over 8–10 days. Disable auto-publish if you need to load more entries in a single import operation, then publish in batches.

Validate the XML before uploading using both `xmllint` (structure) and a manual character encoding check:

```bash
# Validate XML structure against Atom schema
xmllint --noout blogger_import.xml

# Check for non-UTF-8 sequences that xmllint may miss
python3 -c "
import sys
with open('blogger_import.xml', 'rb') as f:
    content = f.read()
try:
    content.decode('utf-8')
    print('UTF-8 valid')
except UnicodeDecodeError as e:
    print(f'Encoding error at byte {e.start}: {e}')
"
```

## Step 5: The Python Migration Script

This complete skeleton connects extraction, transformation, and Atom file generation. Unlike the abbreviated snippets above, the `build_atom_entry` function here correctly receives `feed` as a parameter:

```python
import mysql.connector
import xml.etree.ElementTree as ET
from xml.sax.saxutils import escape
from datetime import datetime, timezone
import re

BLOG_ID = "YOUR_BLOG_ID"
ATOM_NS = "http://www.w3.org/2005/Atom"
THR_NS = "http://purl.org/syndication/thread/1.0"

def sanitize_for_xml(text):
    if not text:
        return ''
    text = text.encode('utf-8', errors='replace').decode('utf-8')
    text = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '', text)
    return text

def extract_pages(cursor):
    cursor.execute("""
        SELECT p.uid, p.title, p.slug, p.crdate, p.tstamp
        FROM pages p
        WHERE p.deleted = 0 AND p.hidden = 0 AND p.doktype = 1
        ORDER BY p.uid
    """)
    return cursor.fetchall()

def extract_content_for_page(cursor, page_uid):
    cursor.execute("""
        SELECT header, bodytext, CType, sorting, colPos
        FROM tt_content
        WHERE pid = %s AND deleted = 0 AND hidden = 0 AND colPos = 0
        ORDER BY sorting
    """, (page_uid,))
    return cursor.fetchall()

def merge_content_elements(elements):
    html_parts = []
    for el in elements:  # already sorted by query
        ctype = el['CType'] if isinstance(el, dict) else el[3]
        header = el['header'] if isinstance(el, dict) else el[0]
        bodytext = el['bodytext'] if isinstance(el, dict) else el[1]
        if ctype in ('text', 'textpic', 'textmedia', 'header'):
            if header:
                html_parts.append(f"<h2>{escape(header)}</h2>")
            if bodytext and ctype != 'header':
                html_parts.append(sanitize_for_xml(bodytext))
    return '\n'.join(html_parts)

def build_atom_entry(feed_element, page, html_body, labels):
    """
    Appends an <entry> to the provided feed_element (ET.Element).
    feed_element: the root <feed> ET.Element
    page: dict with uid, title, crdate keys
    html_body: merged HTML string
    labels: list of label strings (max 20)
    """
    entry = ET.SubElement(feed_element, 'entry')
    post_id = str(page['uid'] + 1000000)

    ET.SubElement(entry, 'id').text = (
        f"tag:blogger.com,1999:blog-{BLOG_ID}.post-{post_id}"
    )

    pub_ts = page['crdate'] if page['crdate'] else int(datetime.now(timezone.utc).timestamp())
    pub_date = datetime.fromtimestamp(pub_ts, tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000+00:00')
    ET.SubElement(entry, 'published').text = pub_date
    ET.SubElement(entry, 'updated').text = pub_date

    kind = ET.SubElement(entry, 'category')
    kind.set('scheme', 'http://schemas.google.com/g/2005#kind')
    kind.set('term', 'http://schemas.google.com/blogger/2008/kind#post')

    for label in labels[:20]:
        cat = ET.SubElement(entry, 'category')
        cat.set('scheme', 'http://www.blogger.com/atom/ns#')
        cat.set('term', sanitize_for_xml(label))

    ET.SubElement(entry, 'title', attrib={'type': 'text'}).text = sanitize_for_xml(page['title'])

    content_el = ET.SubElement(entry, 'content', attrib={'type': 'html'})
    content_el.text = html_body  # ET handles XML escaping for .text assignment

    author = ET.SubElement(entry, 'author')
    ET.SubElement(author, 'name').text = 'Author Name'
    ET.SubElement(author, 'email').text = 'noreply@blogger.com'

    return entry

def build_feed_root():
    ET.register_namespace('', ATOM_NS)
    ET.register_namespace('thr', THR_NS)
    feed = ET.Element(f'{{{ATOM_NS}}}feed')
    ET.SubElement(feed, 'id').text = f"tag:blogger.com,1999:blog-{BLOG_ID}.archive"
    ET.SubElement(feed, 'updated').text = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000+00:00')
    ET.SubElement(feed, 'title', attrib={'type': 'text'}).text = 'Your Blog Title'
    ET.SubElement(feed, 'generator').text = 'Blogger'
    author = ET.SubElement(feed, 'author')
    ET.SubElement(author, 'name').text = 'Author Name'
    ET.SubElement(author, 'email').text = 'noreply@blogger.com'
    return feed

def main():
    conn = mysql.connector.connect(
        host='localhost', database='typo3db',
        user='user', password='password',
        charset='utf8mb4', use_unicode=True
    )
    cursor = conn.cursor(dictionary=True)

    pages = extract_pages(cursor)
    feed = build_feed_root()

    for page in pages:
        elements = extract_content_for_page(cursor, page['uid'])
        if not elements:
            continue
        html_body = merge_content_elements(elements)
        build_atom_entry(feed, page, html_body, labels=[])

    tree = ET.ElementTree(feed)
    ET.indent(tree, space='  ')
    tree.write('blogger_import.xml', encoding='utf-8', xml_declaration=True)
    print(f"Wrote {len(pages)} entries to blogger_import.xml")

if __name__ == '__main__':
    main()
```

This is working skeleton code, not production-ready output. A real migration script needs: FAL image URL rewriting, `t3://` link resolution, EXT:news extraction, label mapping from `sys_category`, batching at the 50MB file size limit, and per-entry error handling that skips malformed records rather than aborting the run.

## The Blogger API v3 Alternative

If generating XML feeds feels brittle, you can push data directly using the **[Blogger API v3](https://developers.google.com/blogger/docs/3.0/reference/posts/insert)**. This requires setting up OAuth 2.0 in the Google Cloud Console and scripting HTTP POST requests to the `posts.insert` endpoint.

```json
POST https://www.googleapis.com/blogger/v3/blogs/BLOG_ID/posts
{
  "kind": "blogger#post",
  "blog": {
    "id": "YOUR_BLOG_ID"
  },
  "title": "TYPO3 Page Title",
  "content": "<p>Concatenated HTML content</p>",
  "published": "2023-10-15T08:00:00Z",
  "labels": ["Migration", "TYPO3"],
  "status": "DRAFT"
}
```

### API constraints

- **Daily creation limit:** ~50–60 posts per blog per day (spam prevention, not formally documented but consistently enforced). A 500-post migration takes 8–10 days.
- **Rate limits:** 100 requests per 100 seconds per user; 10,000 API requests per day per project ([source](https://developers.google.com/blogger/docs/3.0/using#quota)).
- **OAuth 2.0 required:** Every write request requires authenticated access via Google Cloud Console credentials with the `https://www.googleapis.com/auth/blogger` scope.
- **No image upload endpoint:** The API accepts HTML content with image URLs but does not upload images to Google's CDN.
- **Drafts:** Set `"status": "DRAFT"` in the API payload to stage the migration before pushing it live.
- **500-post hard cap:** The 500-post limit applies regardless of whether you use the API or file import.

For sites with more than ~200 posts, the Atom file import is faster because a single file can contain hundreds of posts (subject to the daily publishing limit and 50MB cap). The API gives more programmatic control per post but is throttled harder and requires managing OAuth token refresh.

## How to Map TYPO3 Pages to Blogger Posts

Not every page in TYPO3's tree should become a Blogger post. The mapping decision depends on what each page represents.

**Map these to Blogger posts:**
- Pages with `doktype = 1` that contain article-like content (news, blog entries, editorial content)
- TYPO3 news extension (`tx_news_domain_model_news`) records — see Step 2 above

**Map these to Blogger static pages:**
- "About" pages, contact pages, legal/imprint pages
- Any page that functions as an evergreen standalone resource

**Do not migrate:**
- Folder pages (`doktype = 254`) — structural containers with no display content
- Shortcut pages (`doktype = 4`) — these redirect to other pages
- Backend user pages, SysFolder content, recycler pages
- Plugin-generated content (search results, form pages, login pages) that has no static representation

### What happens to TYPO3's page hierarchy?

It's gone. Blogger has no concept of nested pages or sections. A TYPO3 site with a structure like `Home > Products > Category A > Product Detail` flattens to a list of posts with labels. The best approximation:

- Convert each level of the TYPO3 page tree into a **label** — e.g., a page under "Products > Category A" gets labels `Products` and `Category A`
- Use Blogger's label-based filtering (`/search/label/Products`) to replicate section landing pages
- Accept that deep hierarchy will lose its navigational structure entirely

## How to Handle Multi-Language Content

TYPO3 manages translations through `sys_language_uid` on `tt_content` records and — depending on TYPO3 version — either `pages_language_overlay` (TYPO3 < 9) or the `l10n_parent` field on the `pages` table itself (TYPO3 9+, where overlay records were merged into the main `pages` table).

**Blogger has no translation layer.** Each language version requires a separate Blogger blog. This is an architectural constraint with no workaround.

To extract only the default language content:

```sql
-- For tt_content: sys_language_uid = 0 is the default language
SELECT * FROM tt_content WHERE pid = %s AND sys_language_uid = 0 AND deleted = 0 AND hidden = 0;

-- For translated content (TYPO3 9+): l10n_parent > 0 identifies translation records
-- Translated pages have l10n_parent pointing to the default-language page uid
SELECT * FROM pages WHERE l10n_parent = 0 AND deleted = 0 AND hidden = 0;  -- default language pages
SELECT * FROM pages WHERE l10n_parent > 0 AND deleted = 0 AND hidden = 0;  -- translation pages
```

For a bilingual site migrating to two Blogger blogs, run the full extraction and transformation pipeline twice — once filtering `sys_language_uid = 0` and once filtering the target language UID — and import to separate blogs.

## How to Handle URL Redirects After Migration

URL structure changes are the highest-risk part of this migration for SEO. TYPO3 allows flexible URL routing via route enhancers, RealURL, or CoolURI — URLs like `/products/widget-pro/` or `/about-us/`. Blogger enforces a rigid, unchangeable date-based URL structure for posts: `YYYY/MM/post-title.html`.

You cannot change Blogger's URL structure. Every migrated page will have a different URL. If you do not map these correctly, you will lose organic search rankings.

### Redirect strategy

1. **Build a redirect map** — Before decommissioning TYPO3, generate a two-column CSV mapping every old TYPO3 URL to its future Blogger URL (`/YYYY/MM/slug.html`)
2. **Implement edge-level 301 redirects** — Route your domain through Cloudflare (or a similar reverse proxy) and handle the 301 redirects via Cloudflare Page Rules or Bulk Redirects. Alternatively, use a lightweight Nginx config or Cloudflare Worker
3. **Do not rely on Blogger's native redirects** — Blogger supports custom redirects via its Settings panel, but there is no bulk CSV upload, no API endpoint for managing redirects, and only simple path-to-path mapping. It is hostile to large migrations

> [!TIP]
> **Keep the old domain alive for redirects.** Even if your blog moves to `yourblog.blogspot.com`, maintain TYPO3's domain with a minimal Nginx config or Cloudflare Worker that 301-redirects every known URL to its Blogger equivalent. This preserves inbound links and search rankings during the transition.

## What You Lose in This Migration

Be explicit with stakeholders about what does not survive:

| TYPO3 feature | Blogger equivalent | Impact |
|---|---|---|
| Page hierarchy / tree structure | None (flat posts + labels) | Navigation model must be redesigned |
| Custom content elements (CTypes) | None | Plugin output, custom layouts lost |
| FlexForm configuration | None | Plugin settings not transferable |
| Backend user roles & permissions | Single owner + admin/author roles | Granular permissions lost |
| TypoScript / Fluid templates | Blogger XML themes | Complete theme rebuild required |
| Multilingual content (`sys_language`) | None (separate blog per language) | Multi-language sites need one blog per language |
| Extension data (`tx_*` tables) | None | EXT:news, EXT:solr, custom extensions all lost |
| Server-side logic (PHP) | None | Form processing, search, dynamic content gone |
| Scheduled publishing workflows | Basic scheduled publishing | Workspace-based review workflows lost |
| File metadata (FAL) | None | Copyright, alt text must be baked into HTML |
| **Post count > 500** | **Hard platform limit** | **Sites >500 posts require multiple blogs** |

## Edge Cases and Failure Modes

**FlexForm content is invisible in SQL.** Many TYPO3 plugins store configuration in `tt_content.pi_flexform` as serialized XML. This data is not human-readable content — it's plugin configuration. If your site relies heavily on plugins like EXT:news, EXT:powermail, or EXT:solr, the rendered output of those plugins is not stored in the database. You'd need to scrape the frontend HTML or use TYPO3's rendering pipeline to extract the final output.

**Multi-language sites require multiple blogs.** TYPO3 handles translations via `sys_language_uid` on `tt_content` and `pages` (with `l10n_parent` in TYPO3 9+, or `pages_language_overlay` in older versions). Blogger has no translation layer. Each language version needs a separate Blogger blog, with a separate import run per language.

**RTE link resolution can silently fail.** If your `bodytext` contains `t3://page?uid=999` and page 999 doesn't exist in your export (deleted, hidden, or in a different page tree branch), the link becomes a dead reference. Scan all `bodytext` content for `t3://` prefixes and validate every target before import.

**Character encoding failures are indistinguishable from XML errors.** TYPO3 databases upgraded from versions prior to 6.x may have column data in Latin-1 despite the database declaring UTF-8. The resulting malformed XML produces the same generic Blogger import error as an unescaped ampersand. Force `utf8mb4` at the MySQL connector level and run the Python encoding validation script above before importing.

**Blogger's import gives poor error feedback.** If the Atom XML is malformed — an unescaped ampersand in one post title, a missing closing tag — the entire import fails with a generic error. Validate the XML against the Atom schema before uploading. A single bad entry aborts the entire file.

**The 500-post cap is not prorated.** Blogger enforces a 500-post limit per blog. If you import 490 posts successfully and attempt to add 20 more, the blog stops accepting new posts. Plan your content volume against this limit before starting. Sites with more than 500 articles require either multiple blogs (with separate domains or subpaths) or a different destination platform.

## When This Migration Doesn't Make Sense

Don't move to Blogger if:

- **Your site has more than 500 posts** — Blogger's hard post cap makes this a blocker, not a preference
- **Your site has more than ~300 pages** — Blogger's daily posting limits and flat content model make large sites painful to migrate and harder to manage long-term
- **You need structured content** — Product catalogs, knowledge bases, multi-section documentation don't fit Blogger's post model
- **You require multilingual support** — Managing separate Blogger blogs per language with no shared content model is an operational burden
- **You need custom functionality** — Forms, search, e-commerce, user login, gated content — none of this exists in Blogger
- **SEO is mission-critical** — The URL structure change and loss of technical SEO controls (canonical tags, structured data, custom sitemaps) make Blogger a downgrade for SEO-heavy sites

Blogger works well for simple personal blogs, small editorial sites under 500 posts, and teams that value zero-cost, zero-maintenance publishing over flexibility. If your TYPO3 site is anything more complex than a blog, consider WordPress, Ghost, or a headless CMS instead.

## Making It Happen

A [TYPO3 to Blogger migration](https://clonepartner.com/blog/migrate/typo3-to-blogger) is technically straightforward but operationally tedious. The hard parts aren't the SQL queries or the Atom XML — they're the content element merging, RTE link resolution, image re-hosting, character encoding normalization, and URL redirect mapping. Each of these steps is simple in isolation and error-prone at scale.

Follow this sequence to prevent data loss:

1. **Audit TYPO3 CTypes** — Run the `GROUP BY CType` query on `tt_content` to identify all content elements that require special parsing. Run the equivalent audit on `tx_news_domain_model_news` if you use EXT:news.
2. **Force UTF-8 at the connector level** — Set `charset='utf8mb4'` on the MySQL connection and run the encoding validation script before generating any XML.
3. **Extract and host media** — Download the `fileadmin/` directory and upload it to your permanent CDN. Rewrite all `<img>` paths before generating the Atom file.
4. **Map the URLs** — Generate a two-column CSV mapping every TYPO3 URL to its future Blogger URL before decommissioning anything.
5. **Resolve internal links** — Scan all `bodytext` and `tx_news_domain_model_news.bodytext` for `t3://` references and replace with absolute target URLs.
6. **Run the transformation** — Execute your script to pull MySQL data, merge content elements, rewrite image URLs, resolve `t3://` links, and generate chunked Atom XML files (max 50MB each).
7. **Validate before importing** — Run `xmllint` and the Python UTF-8 check on every output file.
8. **Import and verify** — Upload the XML files via the Blogger UI. Disable auto-publish for large imports. Verify internal links and image rendering on a sample of posts.
9. **Deploy edge redirects** — Upload your URL mapping to Cloudflare or a lightweight proxy to handle 301 redirects before switching DNS.

If your site has fewer than 100 pages with clean text content and minimal media, a single engineer can handle this in a weekend. Once you're past 200 pages with FAL images, categories, internal links, and an EXT:news component, the scripting and QA effort grows faster than the content volume suggests.

> Need help moving from TYPO3 to Blogger — or to any other platform? ClonePartner's engineering team handles the extraction, transformation, image re-hosting, and redirect mapping so you don't have to. Book a 30-minute call to scope your migration.
>
> [Talk to us](https://cal.com/clonepartner/meet?duration=30)

## Frequently asked questions

### Can I automatically migrate from TYPO3 to Blogger?

No. There is no automated tool or plugin for TYPO3-to-Blogger migration. TYPO3's EXT:impexp exports proprietary T3D/XML that Blogger cannot read. You need custom scripts to extract from TYPO3's MySQL database, transform content elements into flat HTML, and generate Blogger's Atom import format.

### What format does Blogger accept for importing posts?

Blogger accepts an Atom XML feed file that follows its specific export format. Each entry must include a tag-URI ID, a category element with the schema kind#post or kind#page, and XML-escaped HTML content. You can also use the Blogger API v3 posts.insert endpoint with JSON.

### How many posts can I import into Blogger per day?

Blogger enforces a daily publishing limit of approximately 50–60 posts. This applies to both the UI file import (with auto-publish enabled) and the Blogger API v3. A 500-post migration requires staging imports over 8–10 days.

### How do I handle images when migrating from TYPO3 to Blogger?

Blogger does not ingest external images during Atom import. You must host TYPO3's fileadmin assets on a CDN or cloud storage, then rewrite image references in your HTML to point to the new URLs. If you decommission the TYPO3 server without re-hosting images, all images in migrated posts will break.

### Will my TYPO3 URLs stay the same in Blogger?

No. Blogger forces a rigid date-based URL structure (YYYY/MM/slug.html) for posts. You must build a redirect map and handle 301 redirects externally via Cloudflare or a lightweight proxy, as Blogger's native redirect manager cannot handle bulk operations.
