Skip to content

Page Builder Content Types

The page builder edits web pages, blog posts, lessons and product pages. None of those are special to it: each is a content type its own app declared, and your app can declare one the same way. A forum, a knowledge base, a job board — anything whose records should have a body a merchant arranges out of sections — becomes editable in the builder without changing the builder.

Declaring a type is two pieces:

  1. a WebsiteBuilderContentType record, seeded from your module’s data/ folder, and
  2. four methods on the model that owns the content.

Your module needs website in its manifest dependencies.

fullfinity/modules/forum/data/builder_content_types.yaml
- data_type: WebsiteBuilderContentType
identifier: topic # the URL segment: /app/website/page-builder/topic/<id>
name: Topic # singular label, shown in the builder
plural_name: Topics # sidebar group heading
model: ForumTopic # the model implementing the methods below
section_field: topic # the relation on WebPageLayout that carries a section
icon: MessagesSquare # any lucide icon name
sequence: 50
list_in_sidebar: true # list this type's records where a page list is offered
can_create: true # offer it in the builder's "New" menu
supports_menu_link: false # offer "add to navigation menu" when creating one
close_action: forum_root_menu/forum_topic_action # where the back button lands
source_module: forum

The record is data, so installing your module registers the type and uninstalling it retires the type — the builder only ever offers types whose declaring module is installed.

A section is a WebPageLayout row. Give it a relation to your model, exactly as the blog and eLearning modules do:

class WebPageLayoutForum(Model):
__inherit__ = "WebPageLayout"
topic = ManyToOne(
"ForumTopic", related_name="sections", on_delete="CASCADE",
description="Topic (if section belongs to a topic)",
)
async def _resolve_website(self):
"""Which site's theme and chrome this section renders against."""
website = await super()._resolve_website()
if website:
return website
await self.fetch_related("topic")
if self.topic:
await self.topic.fetch_related("website")
return self.topic.website
return None

The name of that relation is what section_field declares, and what WebPageLayout.create_from_template(..., owner_field="topic", owner_id=…) attaches through.

They are called through the ordinary execute endpoint, so they run as the logged-in user with your model’s own access rules applied — declaring a content type grants no reach your model does not already have. All four must be public (a leading underscore is never invokable).

Everything the builder needs to open one record.

async def builder_payload(self):
await self.fetch_related("website")
website = self.website
return {
"id": self.id,
"name": self.title, # what the settings panel's title box edits
"slug": self.slug or "",
"website": {"id": website.id, "identifier": website.identifier, "name": website.name},
"preview_path": f"/forum/{self.slug}", # the PUBLIC path the preview iframe loads
"sections_owner": {"field": "topic", "id": self.id},
"publish": {"kind": "switch", "value": bool(self.published)},
"seo": {
"meta_title": self.meta_title or "",
"meta_description": self.meta_description or "",
"og_image": self.og_image or "",
"noindex": bool(self.seo_noindex),
},
"labels": {"name": t("Topic Title"), "slug_placeholder": "my-topic"},
}

preview_path is the whole reason live preview works for a type nobody hardcoded. Only your model knows where its records are published — under a parent’s slug, behind an app route, at the site root. Return the real public path and the preview, the “open in new tab” link and every preview reload all follow it.

sections_owner is deliberately separate from the record. Usually it points back at the record itself. It exists because it does not always: a product page’s layout is the shared storefront template (edit once, every product gets it) while its name, URL and SEO are the product’s, so a product returns {"field": "page", "id": <template page id>}.

Optional keys:

KeyEffect
publish.kindswitch (a boolean), select (with choices), or none to offer no publish control
seonull to hide the SEO section entirely — for a type that is not a search destination
seo.noindexnull if your model has nowhere to store a robots directive; the toggle is then not shown
seo_placeholders{meta_title, meta_description} — what the page emits when the overrides are empty, shown as placeholder text so an empty box reads as “automatic”
labels{name, slug_placeholder, og_image, og_image_hint} — captions in your model’s vocabulary
slug_lockedtrue when the app owns the URL and editing it would only break the route serving it
shared_layout{name, note} — declare this whenever sections_owner points somewhere other than the record itself

If your sections live on a shared layout, say so in shared_layout. The builder is opened from one record, previews that record and binds its settings panel to it, so nothing on screen suggests an edit reaches anything else — and then removing a section empties it everywhere. When shared_layout is present the builder titles the section list after the layout rather than the record, shows a standing notice above it, and repeats the warning in the delete confirmation:

"sections_owner": {"field": "page", "id": template_page.id},
"shared_layout": {
"name": t("Product Page"),
"note": t("These sections are the shared Product Page layout — every product on this "
"site is drawn with them."),
},

builder_save_settings(values) — required

Section titled “builder_save_settings(values) — required”

The panel hands back a normalised dict — name, slug, meta_title, meta_description, og_image, noindex, publish — and your model maps it onto its own field names. This is the only place those names appear.

async def builder_save_settings(self, values):
await self.update(
title=values.get("name"),
slug=values.get("slug") or "",
meta_title=values.get("meta_title") or "",
published=bool(values.get("publish")),
)
return {"ok": True}

builder_resolve(website_id, path) — required

Section titled “builder_resolve(website_id, path) — required”

The inverse of the preview_path your builder_payload publishes: given a public path a visitor clicked in the preview, the record your type would serve it with. This is what makes the preview navigable — the site is browsed the way a visitor browses it, and each link opens the record behind the page it lands on.

async def builder_resolve(cls, website_id, path):
parts = [p for p in (path or "").split("?")[0].split("#")[0].strip("/").split("/") if p]
if len(parts) != 2 or parts[0] != "forum":
return None # not ours — never guess
topic = await cls.filter(website__id__eq=website_id, slug__eq=parts[1]).first()
return {"id": topic.id} if topic else None

Answer None for anything you do not serve: the builder asks each type in turn and the first claim wins, so a loose match opens another app’s record. A path nothing claims is not an error — the preview simply follows the link, so pages with no record behind them (an index, a search result) stay browsable.

builder_list(website_id) — required when list_in_sidebar

Section titled “builder_list(website_id) — required when list_in_sidebar”

Rows for a page list.

async def builder_list(cls, website_id):
topics = await cls.filter(website__id__eq=website_id).order_by("title ASC").all()
return [{"id": topic.id, "name": topic.title, "slug": topic.slug or ""} for topic in topics]

builder_create(website_id, name, slug, add_to_menu=False) — required when can_create

Section titled “builder_create(website_id, name, slug, add_to_menu=False) — required when can_create”

What “a new one” means for your type. Return {"id", "name", "slug"}; the builder switches to the new record. Honour add_to_menu only if you declared supports_menu_link.

Give the record an action, and the standard route resolves it against the registry:

async def action_open_page_builder(self):
return {
"type": "url",
"url": f"/app/website/page-builder/topic/{self.id}",
"target": "self",
}

Rendering the sections on your own public page is the ordinary section-rendering path — see Website Themes for section templates, which your module can also ship so the builder’s palette offers blocks made for your content.

Pages your app renders: WebsiteTemplatePage

Section titled “Pages your app renders: WebsiteTemplatePage”

A content type covers records a user creates. A different case is the page your app owns — a shop listing, a blog index, a catalog — where the app decides the live content but the site owner should still be able to put a hero above it and a newsletter block below it.

Declare it and you get an ordinary, fully editable WebPage whose body carries your live content as a locked section:

fullfinity/modules/forum/data/template_pages.yaml
- data_type: WebsiteTemplatePage
identifier: template_page_forum
name: Forum Home
page_type: forum # stamped on the page; its sentinel slug is /__forum__
route: /forum # where visitors reach it, and how a preview click finds it
core_section: forum_topic_list # your locked section template — the live part
companion_sections: # optional: what a new site starts with around the core
- forum_rules
sequence: 50

Then render it from your route instead of a fixed template:

html = await self._render_template_page(request, "forum", website, mock_page, render_context)

Website.get_template_page("forum") returns that page, creating it if the site has never had one and restoring the core section if someone deleted it. Your section template reads the route’s context for its live content (context_provider supplies the same shape to the builder’s preview); everything else on the page is the owner’s to arrange.

Mark the core section locked: true in its section template — it is what makes the page that page, and a deletable core leaves a shop with no shop on it.

Theme, brand and site chrome, the section palette and its live in-place preview, drag reordering, draft-vs-saved state, the unsaved-changes guard, and the mobile/tablet viewport switcher. Those are the builder’s, not the type’s.