From multiple page types to one universal model

Configuration, not type

Geschrieben von Timo Rieber am 3. Februar 2026

texsite, my open-source Wagtail package, had three page models for three visual themes. CleanBlogPage, BusinessCasualPage, XperiencePage - each with its own StreamField definition, its own migrations, its own admin entry. All inheriting from the same BasePage. Every new block type meant three model changes and three migrations. Not dangerous, but friction that scaled linearly with every feature.

When duplication is configuration in disguise

The StreamField definitions looked different on the surface. CleanBlog had seven atomic block types - heading, paragraph, image, each as a standalone stream entry. BusinessCasual had four composite blocks: ContentBlock bundled a heading, an optional image, and a paragraph into one unit. Xperience used bare ListBlocks for carousels and promoted pages, with a teaser_image wired to an ImageChooserBlock.

The data underneath was the same: headings, paragraphs, images, documents, people, contact information. The specialization lived entirely in how blocks were composed and which templates rendered them. The type hierarchy was encoding a theme selector.

SiteBranding - a site-level setting for logo and favicon - was the natural home for a theme field. Moving the theme decision there made it a runtime property of the site, not a permanent choice baked into the page type.

The page that doesn't know its theme

One model, all fourteen block types, one method:

def get_template(self, request, *args, **kwargs):
    branding = SiteBranding.for_request(request)
    theme = branding.theme if branding else 'cleanblog'
    return f'texsite{theme}/universal_page.html'
Python

The page model has zero awareness of which theme renders it. A template tag does the same per block - resolving texsite{theme}/blocks/{block_type}.html at render time, falling back to a core default.

What the consolidation proved

Three StreamField schemas had to collapse into one. CleanBlog mapped directly. Xperience needed renaming and wrapping. BusinessCasual required decomposing composite blocks - a ContentBlock that bundled heading, image, and paragraph turned out to be styled text and an optional image. The schemas were closer than the type hierarchy had implied.

Days after the consolidation, a fourth theme - Freelancer - went in. One line in THEME_CHOICES, one migration for the choices field, one directory of templates. No model change, no StreamField change, no data migration. Before the consolidation, that would have been a new page model, new blocks module, new migrations - the same structure cloned a fourth time.

When specialization lives in templates rather than data, it's configuration - not type.