Skip to main content

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:

  1. <html lang={site.settings.language || "en"}> — language for accessibility / SEO
  2. <link rel="stylesheet" href={"/" + site.themeCssPath} /> — the theme's CSS
  3. <meta name="x-cms-head-extra" /> sentinel inside <head> — replaced post-render with plugin head injections
  4. <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.

  1. <title> + <meta name="description"> — for SEO
  2. <link rel="canonical"> — based on currentPath + site.settings.baseUrl
  3. <meta property="og:*"> Open Graph tags for social previews
  4. 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. Use posts[] (or listHtml / heroHtml when your theme delegates to its own block renderers).
  • "static-page": render staticPage.bodyHtml instead of the post grid. The page's body is already rendered through marked + post.html.body filters.
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:

  1. Converted from Markdown
  2. Sanitised by DOMPurify
  3. Run through every post.html.body filter (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.

Continue