Murit CMS Developer Documentation
Integrate your website or application with Murit CMS in minutes. Author centrally, deliver instantly, and eliminate full-site rebuilds.
Introduction
Murit CMS is an independent, multi-tenant headless content management platform built on Payload 3 and Next.js 16. It enables organizations to manage multiple distinct websites from a single, unified editorial console while providing client applications with a normalized, read-only Content API.
Traditional static site publishing requires a full CI/CD deployment or git commit every time an article is edited. Murit CMS decouples content publishing from website deployments:
ETag) and shared-cache headers without rebuilding.Core Concepts
| Concept | Description | Safety / Scope |
|---|---|---|
| Public Site Key | An immutable identifier (e.g. site_abc123) representing one specific website. | Public identifier. Safe to include in client code or frontend environment variables. |
| Content API v1 | Read-only REST contract serving normalized DTOs, sanitized HTML, and image metadata. | Completely isolated from Payload database internals and sensitive admin fields. |
| Sanitized Lexical HTML | Posts deliver pre-rendered contentHtml ready for direct embedding. | Sanitized against XSS on the CMS server before dispatch. |
| Signed Preview Token | A 5-minute cryptographic token granting access to a saved unpublished draft. | Private. Must never be logged, cached, or exposed publicly. |
Quickstart
To connect any client site, set two environment variables in your application:
CMS_BASE_URL="http://127.0.0.1:3001" # Or your production Murit CMS origin CMS_SITE_KEY="site_your_registered_site_key"
Public Content API v1 Reference
All endpoints are mounted under /api/content/v1/sites/:siteKey. All requests are read-only (GET).
Returns the public metadata and configuration for the specified site.
{
"data": {
"siteKey": "site_demo_acme",
"name": "Acme Tech Blog",
"defaultLocale": "en",
"primaryURL": "https://acme.com",
"blogBasePath": "/blogs/",
"timezone": "UTC",
"seoDefaults": {}
}
}Returns a paginated list of published post summaries. Does not include full body HTML for optimal payload size.
| Parameter | Type | Description |
|---|---|---|
limit | number (1-50) | Optional. Default is 20. |
cursor | string | Opaque cursor string for pagination. Pass the nextCursor from previous response. |
category | string | Optional slug filter for category. |
tag | string | Optional slug filter for tag. |
{
"data": [
{
"id": "123",
"title": "Decoupling Publishing from Deployments",
"slug": "decoupling-publishing-from-deployments",
"excerpt": "How Murit CMS decouples content publishing from website deploys.",
"publishedAt": "2026-09-06T12:00:00.000Z",
"modifiedAt": "2026-09-06T12:00:00.000Z",
"featuredMedia": {
"url": "https://cdn.domain.com/media/article-cover.webp",
"alt": "System diagram",
"caption": null,
"width": 1200,
"height": 630
},
"categories": [{ "name": "Engineering", "slug": "engineering" }],
"tags": [{ "name": "Architecture", "slug": "architecture" }],
"seo": {
"title": "Decoupling Publishing from Deployments",
"description": "How Murit CMS decouples content publishing from website deploys.",
"canonicalURL": "https://domain.com/blog/decoupling-publishing-from-deployments/",
"keywords": null,
"socialImage": {
"url": "https://cdn.domain.com/media/article-cover.webp",
"alt": "System diagram",
"caption": null,
"width": 1200,
"height": 630
}
}
}
],
"meta": {
"limit": 20,
"nextCursor": "eyJpZCI6MTIzfQ=="
}
}Returns a single published article by slug with full metadata and sanitized contentHtml.
{
"data": {
"id": "123",
"title": "Decoupling Publishing from Deployments",
"slug": "decoupling-publishing-from-deployments",
"excerpt": "How Murit CMS decouples content publishing from website deploys.",
"publishedAt": "2026-09-06T12:00:00.000Z",
"modifiedAt": "2026-09-06T12:00:00.000Z",
"featuredMedia": { "url": "https://cdn.domain.com/media/cover.webp", "alt": "Cover Image", "caption": null, "width": 1200, "height": 630 },
"contentHtml": "<p>When content authors update copy, you shouldn't have to wait for CI/CD...</p><h2>How It Works</h2><p>Our Content API serves cached responses with automatic ETag revalidation.</p>",
"categories": [{ "name": "Engineering", "slug": "engineering" }],
"tags": [{ "name": "Architecture", "slug": "architecture" }],
"seo": {
"title": "Decoupling Publishing from Deployments",
"description": "Complete breakdown of the v1 Content API contract.",
"canonicalURL": "https://domain.com/blog/decoupling-publishing-from-deployments/",
"keywords": null,
"socialImage": {
"url": "https://cdn.domain.com/media/cover.webp",
"alt": "Cover Image",
"caption": null,
"width": 1200,
"height": 630
}
}
}
}Accepts limit (1-500) and cursor. Powers your dynamic sitemap without needing to load bulky article bodies.
Redeems an ephemeral 5-minute cryptographic token issued by Murit CMS to inspect the latest saved draft before it is publicly published. Returns the full post DTO with Cache-Control: private, no-store.
Returns { posts: [...] } matching the exact Astro 5 & legacy blog schema with zero-double-slash root URLs, slug preservation (casing & hyphens retained), rich-text Lexical HTML (including YouTube embeds and X/Twitter embeds), and redirectUrls for 301 legacy mappings.
Note: You can also append ?format=astro to /posts and /posts/{slug} to receive this flat Astro DTO structure.
{
"posts": [
{
"id": "ibc-supreme-court-ruling",
"title": "IBC Supreme Court Landmark Ruling",
"date": "2024-03-15T10:00:00.000Z",
"excerpt": "Comprehensive legal summary of the IBC ruling.",
"image": "https://cdn.domain.com/media/hero.png",
"imageAlt": "Court gavel",
"imageWidth": 1200,
"imageHeight": 630,
"seoImage": "https://cdn.domain.com/media/hero.png",
"seoImageWidth": 1200,
"seoImageHeight": 630,
"categories": ["Corporate Law"],
"tags": ["IBC"],
"seoKeywords": "ibc, supreme court, insolvency",
"seoTitle": "IBC Supreme Court Landmark Ruling",
"seoDescription": "Comprehensive legal summary of the IBC ruling.",
"contentHtml": "<h2>Overview</h2><p>Article content...</p>",
"contentMarkdown": "## Overview\n\nArticle content...",
"updatedAt": "2024-03-16T09:00:00.000Z",
"lastUpdatedAt": "2024-03-16T09:00:00.000Z",
"legacyUrl": "https://rjls.in/ibc-supreme-court-ruling/",
"redirectUrls": ["https://rjls.in/ibc-supreme-court-ruling/"]
}
]
}Authenticated endpoint for automated legacy blog migration (e.g. from WordPress, Payload CMS, or JSON feeds). Available directly in the Admin Console under Website Settings > Legacy Blog Migration and Overview. CMS operators must first deploy the registered 20260909_122610_legacy_redirects database migration, which adds legacy URL fields to posts and saved versions. Frontend clients do not run database migrations.
| Payload Field | Type | Description |
|---|---|---|
websiteId | number | Target website ID (required). Must have site manager access. |
action | "preview" | "import" | preview returns article counts, detected categories, and sample cards without saving. import converts and persists posts, taxonomies, and media. |
endpointURL | string (optional) | Remote REST API endpoint returning articles. |
posts | array (optional) | Raw article array or object containing posts array. |
Framework Integration: Next.js (App Router)
Create a modern blog listing page in your Next.js application:
import Link from 'next/link';
interface PostSummary {
title: string;
slug: string;
publishedAt: string;
seo?: {
description?: string;
socialImage?: { url: string; alt?: string };
};
}
export default async function BlogIndexPage() {
const res = await fetch(
`${process.env.CMS_BASE_URL}/api/content/v1/sites/${process.env.CMS_SITE_KEY}/posts?limit=12`,
{ next: { revalidate: 60 } } // 60-second stale-while-revalidate
);
if (!res.ok) throw new Error('Failed to fetch articles');
const { data: posts } = (await res.json()) as { data: PostSummary[] };
return (
<div className="max-w-4xl mx-auto px-4 py-16">
<h1 className="text-4xl font-bold text-white mb-8">Articles</h1>
<div className="grid gap-8">
{posts.map((post) => (
<article key={post.slug} className="p-6 rounded-xl border border-zinc-800 bg-zinc-900/50">
<h2 className="text-2xl font-bold text-white hover:text-sky-400">
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
</h2>
<p className="text-zinc-400 mt-2">{post.seo?.description}</p>
<time className="text-xs text-zinc-500 mt-4 block">
{new Date(post.publishedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})}
</time>
</article>
))}
</div>
</div>
);
}And create the dynamic article page rendering the sanitized HTML:
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const res = await fetch(
`${process.env.CMS_BASE_URL}/api/content/v1/sites/${process.env.CMS_SITE_KEY}/posts/${slug}`,
{ next: { revalidate: 60 } }
);
if (!res.ok) return { title: 'Post Not Found' };
const { data: post } = await res.json();
return {
title: post.seo?.title || post.title,
description: post.seo?.description,
};
}
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params;
const res = await fetch(
`${process.env.CMS_BASE_URL}/api/content/v1/sites/${process.env.CMS_SITE_KEY}/posts/${slug}`,
{ next: { revalidate: 60 } }
);
if (res.status === 404) notFound();
if (!res.ok) throw new Error('Failed to fetch post');
const { data: post } = await res.json();
return (
<article className="max-w-3xl mx-auto px-4 py-16">
<h1 className="text-4xl font-extrabold text-white mb-4">{post.title}</h1>
<time className="text-sm text-zinc-400 mb-8 block">
Published on {new Date(post.publishedAt).toLocaleDateString()}
</time>
{/* Sanitized server-side HTML */}
<div
className="prose prose-invert max-w-none mt-8"
dangerouslySetInnerHTML={{ __html: post.contentHtml }}
/>
</article>
);
}Framework Integration: Astro 5 (SSG & SSR)
Astro is ideal for content-heavy sites and migrated legacy blogs. Central-CMS provides a dedicated endpoint /api/content/v1/sites/{siteKey}/legacy-posts that serves a { posts: [...] } envelope optimized for Astro's getStaticPaths().
Create a dynamic catch-all route at src/pages/[...slug].astro (or under src/pages/blogs/[...slug].astro):
---
// src/pages/[...slug].astro (Astro 5 SSG & SSR)
export async function getStaticPaths() {
const CMS_BASE = import.meta.env.CMS_BASE_URL || 'http://127.0.0.1:3001';
const SITE_KEY = import.meta.env.CMS_SITE_KEY;
const res = await fetch(`${CMS_BASE}/api/content/v1/sites/${SITE_KEY}/legacy-posts`);
if (!res.ok) throw new Error(`Failed to fetch posts: ${res.status}`);
const { posts } = await res.json();
return posts.map((post: any) => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{post.seoTitle || post.title}</title>
<meta name="description" content={post.seoDescription || post.excerpt || ''} />
<link rel="canonical" href={new URL(Astro.url.pathname, Astro.site).toString()} />
{post.seoKeywords && <meta name="keywords" content={post.seoKeywords} />}
</head>
<body>
<article class="prose max-w-4xl mx-auto py-12 px-4">
<header class="mb-8">
<h1 class="text-4xl font-bold">{post.title}</h1>
<div class="text-sm text-zinc-500 mt-2">
Published on {new Date(post.date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
</div>
</header>
{post.image && (
<img
src={post.image}
alt={post.imageAlt || post.title}
class="w-full rounded-xl mb-8 object-cover max-h-[500px]"
/>
)}
<!-- Render full pre-rendered HTML with Lexical & embed support -->
<div set:html={post.contentHtml} />
{/* Related Article cards are already pre-rendered inside contentHtml. */}
</article>
</body>
</html>ETags & Caching Policy
All Content API responses include an ETag header and recommend caching:
Cache-Control: public, s-maxage=60, stale-while-revalidate=300 ETag: W/"d14-3a9b1c2" X-Content-Type-Options: nosniff
Consumers sending If-None-Match will receive 304 Not Modified with zero payload bandwidth when content hasn't changed.
Zero-Dependency TypeScript Helper
Drop this lightweight helper into your project at lib/murit.ts for fully typed Content API access:
const CMS_BASE = process.env.CMS_BASE_URL || 'http://127.0.0.1:3001';
const SITE_KEY = process.env.CMS_SITE_KEY!;
export async function getPosts(params?: { limit?: number; category?: string; tag?: string }) {
const query = new URLSearchParams();
if (params?.limit) query.set('limit', String(params.limit));
if (params?.category) query.set('category', params.category);
if (params?.tag) query.set('tag', params.tag);
const res = await fetch(`${CMS_BASE}/api/content/v1/sites/${SITE_KEY}/posts?${query}`, {
next: { revalidate: 60 }
});
if (!res.ok) throw new Error(`Murit API error: ${res.statusText}`);
return res.json();
}
export async function getPost(slug: string) {
const res = await fetch(`${CMS_BASE}/api/content/v1/sites/${SITE_KEY}/posts/${slug}`, {
next: { revalidate: 60 }
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Murit API error: ${res.statusText}`);
const json = await res.json();
return json.data;
}Webhooks & On-Demand Revalidation
Murit CMS can dispatch HTTP POST webhooks to your frontend whenever content changes (post.published, post.updated, post.deleted). Configure your webhook endpoint in your website settings within the Payload Admin Console.
In Next.js App Router, implement an instant revalidation route handler:
import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const secret = req.headers.get('x-murit-secret');
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ message: 'Invalid token' }, { status: 401 });
}
const payload = await req.json();
if (payload.doc?.slug) {
revalidatePath(`/blog/${payload.doc.slug}`);
}
revalidatePath('/blog');
return NextResponse.json({ revalidated: true, now: Date.now() });
}OpenAPI 3.1 Machine Specification
Murit CMS provides an official OpenAPI 3.1 specification at /openapi.json and /api/content/v1/openapi.json. You can import this directly into Postman, Swagger UI, Insomnia, or auto-generate TypeScript clients with openapi-typescript or orval.
Are you vibe coding with Cursor or Claude?
Check out our dedicated AI & Vibe Coding Hub for ready-to-use prompts, /llms.txt context, and the Model Context Protocol (MCP) server!