Skip to content

TYPO3 to Blogger Migration: A Technical Guide

A technical guide to migrating from TYPO3 to Blogger. Covers database extraction, content element merging, Atom import format, API limits, and URL redirects.

Abdul Aleem Abdul Aleem · · 20 min read
TYPO3 to Blogger Migration: A Technical Guide
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

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) or the Blogger API v3, 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 (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.

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:

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:

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:

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;
Info

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

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:

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:

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:

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:

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="/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). 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 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:

# 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:

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. This requires setting up OAuth 2.0 in the Google Cloud Console and scripting HTTP POST requests to the posts.insert endpoint.

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).
  • 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:

-- 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 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.

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.

More from our Blog

WordPress to Wix Migration: A Technical Guide
Migration Guide/General

WordPress to Wix Migration: A Technical Guide

A technical guide to migrating WordPress to Wix — what actually transfers, URL structure conflicts, WooCommerce limits, SEO preservation, and the manual work most guides skip.

Abdul Wahab Abdul Wahab · · 24 min read