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:
- a
WebsiteBuilderContentTyperecord, seeded from your module’sdata/folder, and - four methods on the model that owns the content.
Your module needs website in its manifest dependencies.
The registry record
Section titled “The registry record”- 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: forumThe 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.
The section relation
Section titled “The section relation”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 NoneThe name of that relation is what section_field declares, and what
WebPageLayout.create_from_template(..., owner_field="topic", owner_id=…) attaches through.
The four methods
Section titled “The four methods”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).
builder_payload() — required
Section titled “builder_payload() — required”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:
| Key | Effect |
|---|---|
publish.kind | switch (a boolean), select (with choices), or none to offer no publish control |
seo | null to hide the SEO section entirely — for a type that is not a search destination |
seo.noindex | null 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_locked | true 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 NoneAnswer 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.
Opening the builder
Section titled “Opening the builder”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:
- 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: 50Then 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.
What you get without doing anything
Section titled “What you get without doing anything”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.