Skip to content
Dee Wan 1.0 is not yet released (pre-MVP). See the 1.0 product boundary.

Blog sites

A site is created as a Website or a Blog. The choice is made once, when the site is created, and cannot be changed later.

  • Website: pages first. Add your own content models in Content Builder when you need them.
  • Blog: posts first. Dee Wan adds one small Post model and shows Posts in the main navigation.

Sites created before this choice existed are Websites.

After the site record is created, Dee Wan runs Blog setup. Setup adds one ordinary content model:

Field Type Required Language
title text yes per language
cover media no per language
body rich text yes per language
publication_date date no shared by all languages

The model is Post (slug post), localized, with title as its display field. Setup adds no components, categories, keywords or other models. Categories, tags and credited authors are an optional add-on: see Categories, tags and authors.

A post’s address is the ordinary content address (its slug). publication_date is a date you show to readers. It does not publish anything.

Posts use the ordinary content lifecycle: the same editor, validation, versions, workflow, review, publish, unpublish, history and permissions as every other model. A post is public only when it is published. Drafts never appear on the public Blog or in Latest posts.

Publishing a post does not rebuild the site. Blog pages read published posts when they are requested.

On a Blog site the sidebar is:

  • Main: Dashboard, Posts, Pages, and Planner for people who may use AI writing (ai:generate).
  • Advanced: Content, and Content Builder for people who can read models.
  • Configuration: Settings, and Installation for superadmins.

An item your role cannot use is not shown, and a group with nothing in it is left out.

Website navigation is unchanged.

Posts is a view of the post model in the ordinary content list: search, workflow filters, cover, title, address, publication date, status and the usual row actions. Creating or opening a post uses the ordinary content editor; its breadcrumb and Cancel return to Posts.

Content and Content Builder still show every model, including post.

Address Shows
/blog Published posts, 12 per page
/blog?page=2 The next 12, and so on
/blog/<post-slug> One published post: title, cover, publication date, body
/blog/category/<slug> Published posts in one category, 12 per page
/blog/tag/<slug> Published posts with one tag, 12 per page
/blog/author/<slug> Published posts credited to one person, 12 per page

Outside the default language, the same addresses start with the language code, for example /th/blog, following the site’s existing locale routing and fallback policy. A post answers at its own slug in each language: /th/blog/<thai-post-slug>.

  • Posts are listed newest publication_date first. Posts without a date come last.
  • page must be a positive whole number. A page past the end answers 404.
  • A Blog with no published posts shows an empty Blog page, not a 404.
  • A missing, draft, unpublished or unavailable-language post answers 404.

A page, Site Chrome route or redirect may not use /blog or anything under it on a Blog site. Such a conflict blocks rendering with blog_route_conflict until you change one of the addresses. One exception: a redirect may start at a post’s own address (/blog/<post-slug>) that no live post uses, so a post published under a new address keeps its old link working. A post’s Search and sharing settings apply to its page.

Each Blog language has an RSS 2.0 feed of its 20 newest published posts:

Address Feed
/rss.xml Posts in the site’s default language
/<code>/rss.xml Posts in another language, /th/rss.xml

Each item has the post’s title, its full address, a summary, its credited authors and its publication date. The summary is the post’s search description, or the start of its text. A post leaves the feed as soon as it is unpublished or moved to Trash, without a rebuild. Every Blog page names its feed in its head, so a feed reader finds it from the page address. A language without its own Blog address has no feed of its own.

Visitors can comment on published posts. Comments are off until you turn them on in Posts → Comments with Accept new comments. That switch is the post model’s Visitor comments setting, the same one any model has; see Visitor comments. Off hides every comment and refuses new ones; nothing is deleted.

New comments wait for moderation. Nothing is shown until you approve it. Each comment is Waiting, Approved, Spam or Refused. Approve, Refuse or Mark as spam one comment, or up to 25 selected at once. Delete permanently removes a comment and its replies.

Action Permission
Read and moderate content:publish
Delete permanently content:publish and delete on post
Turn comments on or off site:settings in Posts → Comments, or model:apply for the model’s Visitor comments switch in Content Builder
Mark Helpful as yourself read on post

Every action sends the state you saw. A comment someone else moved meanwhile is left as it is, and the list is read again. Moderation never sends notifications.

  • A comment belongs to its post and one language, not to the address. Renaming a post keeps its comments. Each language has its own thread.
  • One reply level. A reply answers an approved top-level comment of the same post and language.
  • Visitors and team members can mark an approved comment Helpful, once each. The count shows on the comment, and Most helpful orders comments by it. Who reacted is never shown.
  • Unpublishing a post or moving it to Trash hides its comments. Publishing it again shows them.
  • Delete permanently in Trash also deletes the post’s comments, and so does the automatic deletion after the Trash retention. The confirmation states how many comments will be deleted.
  • A comment stores the display name, an optional website, an optional email, the text, the consent, the time and the state. The email is private: no public read, page or cache carries it. Dee Wan never emails a visitor. A comment without an email shows Not provided in its details.
  • The form asks for a name, the comment and consent. Website and email are optional and are checked only when filled in. The consent covers only what the visitor enters.
  • A comment imported without consent, such as one from a WordPress export, shows No consent. Its source never recorded that the person accepted this site’s comment form, and nothing in Dee Wan changes that. It arrives pending and visitors do not see it. Publish imported comment… shows it to visitors after you confirm the dialog. That is your decision to republish it; it does not record consent, and the comment keeps No consent. Settings → History records who published it and when, never the text. Approving a selection skips these comments and says how many need review one by one. Refuse, Mark as spam and Delete permanently stay available. Moving a published imported comment out of Approved hides it again; showing it again needs Publish imported comment… once more.
Limit Value
Name 80 characters
Website 200 characters, https:// or http://
Email 254 characters, optional
Comment 4,000 characters
Comments per client and site 10 every 10 minutes, apart from forms
Reactions per client and site 60 every 10 minutes, apart from comments
Replies shown under one thread 20
Count shown up to 999, then 999+

Each form carries a key. Sending the same comment again with its key answers with the first receipt; the same key with different text is refused. A hidden field catches simple bots. The limit counts a keyed hash of the visitor’s address, never the address, and the count is erased after a day.

A post page shows approved comments, 20 threads a page (?comments_page=2), and the form. Visitors sort them Oldest (the default), Newest or Most helpful (?comments_order=), and each comment has a Helpful button. The form works without JavaScript: it posts to /v1/interactions/comment.submit on the site’s own host and returns to the post with the outcome; Helpful posts to /v1/interactions/comment.react the same way. A generated site also sends it in place and shows busy, error, repeat, limit and success states without leaving the page. A preview never takes a comment. The intake route <host>/v1/interactions/* must reach the intake Worker, as for website forms. Renderers read comments from GET /v1/_comments; see the public API.

A site copy and a site history transfer carry the comments of every exported post in comments.jsonl, with their state, private email (or null when none was given) and reaction counts. Who reacted does not travel. Importing recreates them on the posts that import created, replies under their own thread. Importing the same copy again never duplicates them, and undoing the import removes them. A comment without consent always arrives pending, even one published on the source site: publish it again on this site if you want it shown. Spreadsheet exports do not include comments. A WordPress import brings reader comments in held for moderation and without consent; see Import from WordPress. Strapi imports never create comments. A database backup includes them like all site data.

An optional add-on for a ready Blog. Open Posts, press ⋯, then Add categories, tags and authors, then Add. You need the right to change models (model:apply). The menu item is shown while the post model lacks any of the three fields below.

It adds ordinary models and fields, nothing else:

Model Slug Fields
Category category name (text, required, per language), parent (one other category, optional)
Tag tag name (text, required, per language)
Person person name (text, required, per language), bio (short text, per language), photo (media, per language)

and three many-to-many relation fields on post: categories → category, tags → tag and authors → person. The three models are public and show name. All three are translated: each language has its own name and address. A person’s bio and photo also belong to each language. parent and a post’s authors list are shared by every language.

  • After setup, you manage their structure in Content Builder, like any other model.
  • The post editor assigns them, with the ordinary relation fields. Authors are shown in the order they were added to the post; to change it, remove an author and add them again.
  • A person is a credited name, not an account. It is never linked to a user, to who created or owns a post, or to who a workflow step is assigned to.

Adding it is safe to repeat. Everything already in place and identical is kept. If a category, tag or person model already exists and differs, or post already has a categories, tags or authors field that differs, nothing is added and the panel lists each one. A category, tag or person model that is not translated is listed as having different language settings. Rename or remove it in Content Builder, then press Try again. All models and fields are added together or not at all.

parent is a hierarchical link. A save that makes a category its own parent, or puts it under one of its own subcategories, is refused and the message is shown on the Parent category field. A restored version is held to the same rule. In Content Builder this is the Hierarchy (parent/child) switch, offered on any to-one relation to its own model; see Models and fields. A generated standalone API keeps parent and refuses the same loops inside its database write.

On Posts, select posts, press Categories and tags, choose Categories or Tags, then Add or Remove, pick up to 10 items and apply. Each post saves a new draft version with its own permissions and version check. Nothing is published. A post that already has the change is left as it is. A post that changed meanwhile, that you may not change, or whose own fields need fixing is listed as not changed, with the reason. Authors are not offered here.

The ordinary content rules apply. A published item must be unpublished before it can go to the Trash, and unpublishing lists the posts that link to it. In the Trash it keeps its links and Restore brings it back. Delete permanently removes the item and its links from posts and keeps the posts.

A post page shows By and its authors under the title, and its categories and tags after the body. Each name links to its archive: /blog/category/<slug>, /blog/tag/<slug> or /blog/author/<slug>, with the language prefix outside the default language.

  • A post uses the current published names and addresses of its categories, tags and authors. Publish a correction to that related item; the post itself does not need another save or publication. Draft edits stay private. Withdrawn, trashed, deleted or private related items are not shown.
  • Existing public caches follow the normal invalidation and cache lifetime rules. The post body and its saved revisions remain unchanged.
  • In each language a category, tag or author shows its own name and links to its own address. Under a strict language policy one without that language is not shown on the post. Under fallback it shows in the language the site falls back to. An archive lists the posts that show that address in its language. The feed credits authors the same way.
  • An archive lists published posts linked directly to that item, newest publication_date first, 12 per page. Posts in a subcategory are not included. An archive with no published post answers 404.
  • A Blog without the add-on answers 404 on these addresses.

If post has a field named categories, tags or authors that is not a relation to category, tag or person, the Blog is not renderer-ready (blog_contract_drift) until it is changed back.

The post body is one rich document, stored as canonical HTML. Every save checks it: markup outside the document is refused by name (field, language, element or attribute) and nothing is written. Dee Wan’s previews and renderers never insert the stored value without processing it: both renderers render it with the page contract’s renderRichDocument.

The document holds paragraphs, headings, lists, quotes, code blocks, rules, bold, italic, underline, strike, inline code, sub- and superscript, links and images, plus:

  • tables, up to 12 columns and 200 rows, with an optional caption, header row and row headers. A wide table scrolls inside its own box on a phone;
  • image galleries of 2 to 24 library images, each with an optional caption;
  • audio, video and file blocks naming one library file of that kind. Audio and video play in the page with a download link under the player; a file is a link with its name, type and size;
  • embeds, made by pasting one https link: YouTube and Vimeo are built in, and an administrator can allow other origins (see below). A block stores the provider id, the canonical https address and a title. It never stores markup, script or CSS.
  • plugin blocks, declared by an installed plugin (see below). A block stores its type, the version it was saved with and its field values. It never stores markup, script or CSS.

Links may be relative, http, https, mailto or tel. Images may be relative, http or https. External links get rel="noopener noreferrer".

The public API serves the validated HTML. Media and embed blocks are data, not players. If you build your own renderer, draw them with renderRichDocument or your own renderer of the same document.

An active plugin can declare rich text block types in its manifest (rich_blocks, see plugins/authoring.md). The editor lists them under Blocks, draws their fields with its own controls and previews each block from the plugin’s declared render tree. Nothing from the plugin runs in the editor or on the site.

  • Stored form: <figure data-block="plugin" data-type="<plugin id>/<name>" data-version="2" data-values="{...}">, with one empty <a data-field data-media-id href> per media field. The values are strings, numbers and booleans as sorted JSON. Media fields are pinned, copied and remapped like any library media.
  • A save that adds or changes a block is checked against the site’s active plugins: an unknown type is refused with rich_block_unregistered, a version newer than declared with rich_block_version_unknown, and values the declared fields refuse with rich_block_values_invalid (each names the field and language). A block saved under an older declared version is accepted when its migrated values pass, and is stored as sent.
  • A block already in the same field and language keeps saving when its plugin is disabled or removed. The editor shows it as kept and not shown on the site. Imports and site copies carry blocks with their versions, whatever is installed on the destination.
  • Older blocks move to the current version only when an author asks: opening a block’s form or Update on the block, or Blocks → Update blocks ({count}). Published content is never rewritten in bulk.
  • Renderers read the definitions from GET /v1/_rich-blocks only for a page that holds a plugin block, and draw each block with the host adapter: a fixed element list, every value escaped, every address checked. When no adapter draws a block (plugin not active, no render tree, or a version newer than the definition), a public page shows nothing for it and a preview shows a placeholder naming it.
  • Search text, search excerpts and feed summaries include a block’s text and textarea values and the labels of its select values, read through the active plugin’s definition at save time. A block with no active definition adds no words. Private fields add none either way.
  • Snippets follow the same save rules: a snippet that adds or changes a plugin block is checked against the active plugins; an unchanged block is kept.
  • The editor lists, under the field, every block the site will not show and why (plugin not installed, plugin turned off, newer version, no layout). Each entry selects its block.
  • Managed and generated sites read the definitions live rather than from the generation payload: their blog posts and lists are read live too, so a pinned copy of the definitions would go stale as soon as a plugin is installed, upgraded or disabled. Region summaries pinned into a generated site at build time show nothing for a plugin block.

Settings → Integrations → Embeds lists the built-in YouTube and Vimeo players and the origins from which this site may embed content in frames. Only people with site:infra see this screen and change the list. The editor reads the list for every member of the site, so anyone who may edit a post can paste an allowed link.

  • An origin is https:// and one exact host, with an optional port. No path, query, fragment, password, wildcard, IP address or local name. A YouTube or Vimeo domain is refused: they are built in.
  • A site holds at most 20 origins, each with a name of 1 to 60 characters.
  • A YouTube watch, share, Shorts, live or player link becomes the privacy-enhanced youtube-nocookie.com/embed/… address. A Vimeo link becomes player.vimeo.com/video/…?dnt=1. A link on an allowed origin is framed as given. Anything else is refused by name: embed_url_malformed, embed_url_not_https, embed_url_credentials, embed_url_ambiguous (a provider link that names no single video) or embed_provider_unknown.
  • The editor never loads the provider. It shows a placeholder, and Preview opens the address in a new tab.
  • Both renderers draw the same frame: sandbox="allow-scripts allow-same-origin allow-presentation allow-popups allow-popups-to-escape-sandbox", allow="encrypted-media; picture-in-picture; fullscreen", referrerpolicy="strict-origin-when-cross-origin", loading="lazy" and a 16:9 box. No entry changes it.
  • Renderers read the allowed origins from GET /v1/_embeds only for a document that holds such an embed. A managed page’s frame-src names the built-in players and those origins only.
  • A save that adds or changes an embed from an origin not on the list is refused with rich_embed_origin_not_allowed, naming the field and language. This holds for the editor, the management API, plugins and spreadsheet import. An embed already in the same field and language keeps saving after its origin is removed, and restoring an older version is not refused: those embeds draw as plain links until the origin is added again. A site copy or Strapi import carries the embeds its source held, the same way. Nothing embeds content from an origin that is not on the list.
  • Only this screen changes the list. The generic settings API refuses to create, change or delete the embed_providers row (setting_server_owned).

The site is kept whatever happens during setup, and Posts shows what to do:

  • Blog setup did not finish (for example a network failure): press Try again. Nothing was marked ready.
  • Blog setup is blocked by an existing “post” model: the site already has a model with the slug post that does not match the starter. Dee Wan leaves it unchanged. Press Open Content Builder, rename or remove that model or make it match, then press Try again.

The state is stored with the site, so a reload shows the same screen. A Blog is not rendered publicly until setup has finished.

Latest posts is a section for any page. Add it from Add section → Patterns → Latest posts. It lists the six newest published posts with their cover, title and publication date.

It is a preset of the Latest entries section, which can list any public collection. In the inspector, choose:

  • Collection: the model to list. It must be public.
  • Title field, and optionally Image field, Summary field and Date field.
  • How many: 1 to 12.
  • Text when nothing is published: shown only when the collection has no published entries.

When a date field is chosen, entries are newest first by that date. Otherwise they are newest published first.

The page stores only these choices, never the entries. If a chosen model or field no longer exists, the inspector names it and the section shows an error instead of an empty list.

A Website with an ordinary public model project (fields name text, photo media, intro rich text, completed_on date) can show its newest projects with:

Choice Value
Collection project
Title field name
Image field photo
Summary field intro
Date field completed_on
How many 3

The projects are created, reviewed and published in Content like other content items. Nothing about the project model changes.

Whole-site generation records Website or Blog, and a Blog’s sorted language list, in the generation source identity and the site manifest. Adding, removing or changing a Blog language changes the public /blog routes, so the deployed source reports as out of date until it is regenerated. Reordering the same languages changes nothing.

npm run backend:generate -- --model post is not a Blog export. Single-model generation emits that model’s API only: no site pages, no /blog routes and no Blog frontend. Generate the whole site instead.

The generated website reads published posts from /v1 on the site’s own host when a Blog page or a page with Latest entries is requested. Latest entries in a shared section shown in a Site Chrome region are read at generation and pinned in the site, so they never make a page render on each request. Its Worker needs the global_fetch_strictly_public compatibility flag for that. New projects get it in apps/site/wrangler.jsonc. That file belongs to you once it exists: Dee Wan never rewrites it. If your existing file lacks the flag, generation and deployment refuse with public_fetch_compatibility_flag_required and name the flag to add. See Detach and verify your standalone site.

The built-in managed template and the generated website draw Latest entries and serve the RSS feeds. A Blog site is not renderer-ready while setup is unfinished, the post model is missing or changed in a way the Blog cannot render (including a categories, tags or authors field that is not the add-on’s relation), or a Blog route collides.

Define or extend your content types in models and fields.


Unreleased / pre-MVP; no stable version
Source revision: fe23db481c422b40e317b92fc98116b4c0c26a87 (modified checkout)
Build date (UTC): 2026-10-18T09:22:34.630Z
Upgrade target: unavailable: modified checkout. Release notes