Templates and props
Each of the six template components receives a typed props object plus a site: SiteContext carrying global state. This page documents what each template gets and how to use the props.
Common: SiteContext
Every template (and BaseLayout) receives site: SiteContext:
interface SiteContext {
settings: SiteSettings; // title, description, language, baseUrl, …
resolvedMenus: {
header: ResolvedMenuItem[]; // already resolved (no raw post/term refs)
footer: ResolvedMenuItem[];
};
themeCssPath: string; // "theme-assets/<id>.css"
themeConfig?: unknown; // user's saved theme settings (cast as needed)
}
themeConfig is unknown because each theme has its own config shape. Cast it in your template:
const cfg = props.site.themeConfig as MyThemeConfig | undefined;
resolvedMenus items have their href already filled in — no need to resolve post/term references yourself.
BaseLayout
The HTML shell wrapped around every page.
Props
interface BaseLayoutProps {
site: SiteContext;
pageTitle: string; // page-specific, NOT including site title suffix
pageDescription?: string;
ogImage?: string;
currentPath: string; // e.g. "news/article.html"
extraHead?: string; // currently unused — sentinel-replaced post-render
children: ReactNode; // the inner template
}
Required output
BaseLayout MUST emit:
<html lang={site.settings.language || "en"}>— language for accessibility / SEO<link rel="stylesheet" href={"/" + site.themeCssPath} />— the theme's CSS<meta name="x-cms-head-extra" />sentinel inside<head>— replaced post-render with plugin head injections<script type="application/x-cms-body-end" />sentinel just before</body>— replaced post-render with plugin body-end injections
Without those sentinels, plugin filters (page.head.extra, page.body.end) silently no-op. Mandatory.
Recommended output
<title>+<meta name="description">— for SEO<link rel="canonical">— based oncurrentPath+site.settings.baseUrl<meta property="og:*">Open Graph tags for social previews- Header + Footer components, calling out
[data-cms-menu="header"]/[data-cms-menu="footer"]containers if you want dynamic menus
See src/themes/default/templates/BaseLayout.tsx for a reference implementation.
HomeTemplate
The home page (/index.html).
Props
interface HomeTemplateProps {
posts: CardPost[]; // most recent online posts (paginated)
staticPage?: { post: Post; bodyHtml: string }; // when homeMode = "static-page"
archivesLink?: ArchivesLink; // injected by flexweg-archives when enabled
heroHtml?: string; // pre-rendered hero block (theme-controlled)
listHtml?: string; // pre-rendered post list (theme-controlled)
mostReadHtml?: string; // sidebar widget (magazine theme)
promoCardHtml?: string; // sidebar widget (magazine theme)
}
Two home modes
settings.homeMode decides which to render:
"latest-posts"(default): show the post grid. Useposts[](orlistHtml/heroHtmlwhen your theme delegates to its own block renderers)."static-page": renderstaticPage.bodyHtmlinstead of the post grid. The page's body is already rendered throughmarked+post.html.bodyfilters.
export function HomeTemplate(props: HomeTemplateProps & { site: SiteContext }) {
if (props.staticPage) {
return <article dangerouslySetInnerHTML={{ __html: props.staticPage.bodyHtml }} />;
}
return <PostGrid posts={props.posts} />;
}
SingleTemplate
A single post or page (/<category>/<post>.html, /<page>.html).
Props
interface SingleTemplateProps {
post: Post;
bodyHtml: string; // already-rendered safe HTML (DOMPurify + filter chain)
author?: AuthorView; // resolved author (display name, avatar, socials)
hero?: MediaView; // resolved hero image
primaryTerm?: Term; // the post's category
tags: Term[]; // the post's tags (already filtered to existing terms)
}
Rendering the body
<article>
<h1>{props.post.title}</h1>
<div className="post-body" dangerouslySetInnerHTML={{ __html: props.bodyHtml }} />
</article>
bodyHtml is trusted — it has already been:
- Converted from Markdown
- Sanitised by DOMPurify
- Run through every
post.html.bodyfilter (theme blocks, plugin embeds, columns)
So dangerouslySetInnerHTML is the right call here. Don't try to sanitise again.
Hero image
{props.hero && (
<img
src={pickFormat(props.hero, "large")}
alt={props.hero.alt ?? ""}
width={props.hero.formats[props.hero.default].width}
height={props.hero.formats[props.hero.default].height}
/>
)}
pickFormat(view, name) falls through requested → default → largest available → empty string. Imported from @flexweg/cms-runtime.
CategoryTemplate
A category archive (/<category>/index.html).
Props
interface CategoryTemplateProps {
term: Term; // the category itself
posts: CardPost[]; // posts in this category (paginated)
categoryRssUrl?: string; // when flexweg-rss enabled this category
archivesLink?: ArchivesLink; // when flexweg-archives enabled
allCategories?: Term[]; // for sidebar navigation (magazine-style themes)
popularTags?: Term[]; // for "Popular Tags" widget (when used)
}
CardPost is Post enriched with computed fields:
type CardPost = Post & {
url: string;
hero?: MediaView;
category?: { name: string; url: string };
dateLabel?: string; // pre-formatted, locale-aware
};
So you don't need to compute URLs or resolve hero images yourself.
AuthorTemplate
An author page (/author/<slug>.html).
Props
interface AuthorTemplateProps {
author: AuthorView; // display name, avatar, bio, socials
posts: CardPost[]; // posts by this author
}
NotFoundTemplate
Renders to /404.html (when the publisher generates one).
Props
interface NotFoundTemplateProps {
message?: string; // optional override for the default 404 message
}
This template is invoked at publish time to produce /404.html. Whether visitors actually see it on a 404 depends on Flexweg's hosting config — Flexweg serves /404.html automatically when no file matches the requested path.
Common patterns
Picking a media format
import { pickFormat, pickMediaUrl } from "@flexweg/cms-runtime";
<img src={pickFormat(post.hero, "small")} alt={post.hero.alt ?? ""} />
// Or for legacy single-URL media (e.g. uploaded before the variant pipeline existed):
<img src={pickMediaUrl(post.hero)} />
pickMediaUrl always works (falls back to the legacy synthesised format); pickFormat is for when you want a specific variant.
Building post URLs
URLs are pre-computed in CardPost.url. For pages where you only have a raw Post, use:
import { buildPostUrl, pathToPublicUrl } from "@flexweg/cms-runtime";
const path = buildPostUrl(post, terms, settings);
const url = pathToPublicUrl(settings.baseUrl, path);
But almost always the publisher has already resolved this — check the props before computing manually.
Reading theme config
const cfg = (props.site.themeConfig as MyConfig | undefined) ?? defaultConfig;
if (cfg.layout === "wide") {
// …
}
themeConfig is the resolved config — manifest defaults merged with user overrides. Already up-to-date at publish time.
Localising dates
import { formatDateTime } from "@flexweg/cms-runtime";
formatDateTime(post.publishedAt, props.site.settings.language)
// → "Apr 15, 2026" (en) / "15 avr. 2026" (fr) / etc.
Or just use the pre-formatted CardPost.dateLabel when available.