cms-renderer
v2.0.5
Published
Median CMS API wrapper — render React typed websites using Median CMS
Readme
cms-renderer
Render a Median website. One class reads it, two components render it, and a Markdown renderer handles rich text.
import { Median, ParametricPage, ParametricPreview } from 'cms-renderer';
import { Markdown } from 'cms-renderer/markdown';
import 'cms-renderer/markdown.css';Median (server or browser)
const median = new Median({
apiKey: process.env.MEDIAN_API_KEY, // in a browser, only a read-only (content_read) key
datasetEndpoint: process.env.DATASET_ENDPOINT!,
websiteId: process.env.VITE_WEBSITE_ID!,
registry, // block type → component; `"/docs/{slug} header"` overrides a type on matching paths
revalidate: 60, // seconds a published page stays in memory (false: until invalidate(), 0: never)
fetchOptions: {}, // extra fetch options for published reads
});| Method | What it does |
|---|---|
| getPage(path, { preview }) | One request: the page's blocks (type, content, layout) and the documents its URL is bound to. Published pages come from /published_page and are cached (concurrent reads share one request); preview: true reads the draft from /draft_page, never cached. null when there is no page. |
| resolveComponent(path, { preview }) | getPage, with each block matched to its registry key (block.component); unregistered blocks are dropped. Plain data: return it from a loader. |
| listPages() | Every published URL, for static builds and sitemaps. |
| documents(schema).fetchAll() / fetchById(id) / fetchByIds(ids) | A component's documents; .translation('fr') for translated content. |
| schemas() / generateSchemas() | The website's components, and Zod schema source (with inferred types) for them. |
| invalidate(path?) | Drop cached published pages, e.g. after a publish (see Publishes). |
Image fields arrive with their URLs resolved; render them with your own image component.
In a browser, listPages, getPage, documents and schemas work the same (the page
service allows cross-origin reads). The key is visible to visitors there, so use a
read-only one, and keep preview reads on the server unless drafts may be public.
ParametricPage / ParametricPreview
Render a resolved page. They never fetch and never see the key.
// TanStack Start
loader: () => getPage({ data: path }), // a server function calling median.resolveComponent(path)
component: () => <ParametricPage page={Route.useLoaderData()} registry={registry} />,
// A CMS preview route: the draft, plus the edit overlay the CMS uses to select and edit blocks
<ParametricPreview page={draft} registry={registry} cmsUrl="https://app.mediancms.com" />Each component receives content, layout, routeParams, language and path.
Next.js: prerendered pages plus a draft preview
// app/[[...slug]]/page.tsx: every published page, prerendered at build
export const dynamicParams = false;
export async function generateStaticParams() {
return (await median.listPages()).map((path) => ({ slug: path.split('/').filter(Boolean) }));
}
export default async function Page({ params }) {
const { slug = [] } = await params;
const page = await median.resolveComponent(`/${slug.join('/')}`);
if (!page) notFound();
return <ParametricPage page={page} registry={registry} />;
}
// app/cms-preview_/[[...slug]]/page.tsx: the live draft, on every request
export const dynamic = 'force-dynamic';
export default async function Preview({ params }) {
const { slug = [] } = await params;
const page = await median.resolveComponent(`/${slug.join('/')}`, { preview: true });
if (!page) notFound();
return <ParametricPreview page={page} registry={registry} cmsUrl="https://app.mediancms.com" />;
}The CMS Template Builder and Canvas load /cms-preview_/<path>. Reference fields
arrive filled with the referenced (published) document's fields. To pick up
publishes without a rebuild, see Publishes.
layout: the block's Appearance settings
What an editor sets under Appearance in the CMS block editor arrives as the
block's layout (BlockLayout), never in content. Every setting is optional:
when it is absent, keep the component's own default.
| Field | Values |
|---|---|
| alignment | 'left', 'center', 'right' (read it with normalizeAlignment) |
| backgroundColor | hex color, e.g. #f5f0e8 |
| textColor | hex color |
| backgroundImageFit | 'cover' (fill, may crop) or 'contain' (whole image) |
| backgroundImagePosition | 'top', 'center', 'bottom' |
export default function Hero({ content, layout }: BlockComponentProps<HeroContent>) {
return (
<section style={{ backgroundColor: layout?.backgroundColor, color: layout?.textColor }}>
<div style={{ backgroundSize: layout?.backgroundImageFit ?? 'cover' }} />
</section>
);
}generateSchemas() emits the same shape as blockLayoutSchema / BlockLayout.
Publishes: keeping a site current
The page service streams every change to what published pages serve (publishes,
rollbacks, published documents; never drafts) as Server-Sent Events from
/content-changes. Each event's id is the website's published version, so a
listener that reconnects with Last-Event-ID hears at once about anything it missed.
Listen from the server process, not the browser. A site that only reacts in an open tab stays stale whenever nobody has it open. Start one subscription when the server starts, keep it for the life of the process, and refresh from there:
| Method | What it does |
|---|---|
| contentChanges({ since, signal }) | The raw stream (fetch response). since: the last version you saw. |
| relayContentChanges(request) | A route handler relaying the stream to browsers, keeping the key on the server. |
| watchContentChanges(url, { since, onChange }) | Browser side of the relay: re-render open tabs. Optional; the server listener is what keeps the site current. |
// The loop every framework below runs once per server process.
let since: string | null = null;
for (;;) {
try {
const response = await median.contentChanges({ since });
for await (const event of sse(response.body)) { // parse `id:` / `event:` / `data:`
since = event.id ?? since;
if (event.event !== 'ready') refresh(); // `ready` only reports the version
}
} catch {}
await sleep(backoff()); // reconnect: 1s, doubling to 30s
}- Next.js (
src/instrumentation.ts):register()starts the loop whenNEXT_RUNTIME === 'nodejs'. Next only allowsrevalidatePathinside a request, sorefresh()POSTs to the site's own route handler, which callsrevalidatePath('/', 'layout'); protect that route with a token. Keep a pagerevalidate(e.g. 60 s) as a fallback. - TanStack Start / Nitro: a Nitro server plugin starts the loop and stops it on
the
closehook;refresh()callsmedian.invalidate()(and rebuilds any cached pages). The docs app does this inapps/docs/server/plugins/docs-content.ts. - Serverless hosts (e.g. Vercel functions) suspend an instance between requests,
so a background listener only runs while one is alive. On those, the page
revalidatefallback is what bounds staleness; a long-running server (Node or Nitro on a container) keeps the stream open continuously.
createCmsProxy (cms-renderer/proxy)
Serves the Median admin panel at the site's own /admin: forwards /admin and the
requests it makes (its files, server functions and API routes) to the CMS, which
recognises the website by x-forwarded-host. Takes a Request, returns the CMS's
Response, or null for the site's own paths.
const proxy = createCmsProxy(); // { upstream } for a self-hosted CMS
// Next.js src/proxy.ts: (await proxy(request)) ?? NextResponse.next()
// TanStack Start middleware: (await proxy(request)) ?? next()Markdown (server)
<Markdown content={markdown} renderImage={...} renderCodeAction={...} renderCard={...} />The parser is WebAssembly embedded in the package, so there is no file to serve. Call
initMarkdown(url | Response) first to stream-compile it from elsewhere instead.
After upgrading md4w, run bun run embed-md4w.
