Home/Docs/Developer Guide

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:

Zero Website Rebuilds: When authors publish or edit in Murit CMS, updates are immediately reflected in the Content API. Client websites retrieve fresh content dynamically using conditional HTTP requests (ETag) and shared-cache headers without rebuilding.

Core Concepts

ConceptDescriptionSafety / Scope
Public Site KeyAn immutable identifier (e.g. site_abc123) representing one specific website.Public identifier. Safe to include in client code or frontend environment variables.
Content API v1Read-only REST contract serving normalized DTOs, sanitized HTML, and image metadata.Completely isolated from Payload database internals and sensitive admin fields.
Sanitized Lexical HTMLPosts deliver pre-rendered contentHtml ready for direct embedding (embeds, Also Read cards, checklists, code blocks, and responsive column grids). Media resized in the editor retain their saved percentage: images carry an inline style="width:{n}%" and videos are wrapped in a .content-video-wrap with the same inline width; full width emits no width. Images may carry a <figcaption> and a link; captioned YouTube/Vimeo embeds are wrapped in figure.content-embed-figure. Imported markup with no safe native block (complex or linked tables, mixed-content galleries, prose-wrapped button groups) arrives as allowlist-sanitized HTML inside div.content-legacy-html; style it like ordinary article HTML. Links keep their SEO rel tokens (nofollow, sponsored, ugc) from the editor or the imported source; new-tab links append noopener noreferrer.Sanitized against XSS on the CMS server before dispatch.
Signed Preview TokenA 5-minute cryptographic token granting access to a saved unpublished draft.Private. Must never be logged, cached, or exposed publicly.
CORS & PreflightCross-Origin Resource Sharing enabled with OPTIONS preflight across all public endpoints and media routes.Public. Allows direct browser-side querying from client SPAs and Astro islands.

Project Setup & Portal Console Guide

Murit CMS provides an intuitive multi-tenant console designed for creators and developers. When onboarding a new project, you can choose between 200 MB free persistent Murit CMS storage shared across your workspace or connect your own cloud storage bucket via Bring Your Own Storage (BYOS).

1. Creating a Project & Generating a Site Key

Click Add Project & Create Site Key (accessible from the sidebar + New button, project switcher dropdown, or the Overview console):

  • Project Details: Set the Name, Organization, Primary URL (e.g. https://domain.com), and optional root or nested Blog Base Path (e.g. / for root-level blogs or /blog).
  • Asset Storage Selection: Choose Murit CMS Storage (200 MB free shared allowance) or Connect your own storage (BYOS). Supported BYOS providers include Cloudflare R2 (recommended for $0 egress and 10 GB free tier), Amazon S3, Google Cloud Storage (GCS), and Other S3-compatible / MinIO storage. Native Azure Blob is not currently supported. You can also optionally enable Content Storage Sync to automatically export JSON/Markdown feeds to your bucket.
  • Optional Integrations: Expand review settings to configure cache invalidation webhooks, fallback SEO defaults (title suffix & description), and Localization & Timezone (supporting 40+ world languages and browser auto-detected timezones).
  • Success & Credentials: Click Create project and site key. The generated CMS_SITE_KEY is displayed with 1-click clipboard copy and ready-to-use framework code snippets.

2. Portal Console & Dedicated Project Management

The administrative portal organizes your workflow across clear, purpose-built surfaces:

  • Workspace Storage Meter Bar: Positioned prominently at the top of the Overview console, displaying total media capacity (200 MB flat shared allowance across all workspace projects, or active unmetered BYOS status with a dynamic proportional project storage distribution bar). Click the bar or Storage Details → to open the dedicated workspace storage console (/admin?view=storage) with project-by-project usage breakdowns.
  • Project Cards & Quick Search: The Overview console displays all created projects as cards with instant search (press / to focus). Each card displays the project name, live domain link, copyable site key, status badges, and deletion/recovery actions.
  • Dedicated Project Page (/admin?project=<id>): Clicking any project card opens its dedicated management page with top breadcrumb navigation (Overview / Project), a direct Create Article launcher, and 3 focused operational tabs:
    • API Connection: View your public site key, live endpoint snippets, interactive documentation links, and AI vibe-coding resources.
    • Project Setting: View and update project identity, URLs, blog base path, timezone, copy public site key, and access the Danger Zone (48-hour project deletion and 1-click reversal). Use “Open Full Schema Editor →” for advanced schema modifications.
    • Migrate Legacy Blogs: Direct 1-click automated importers for WordPress, Payload CMS, or custom JSON article archives with slug, media, and SEO preservation.
  • 48-Hour Project Deletion Grace Period: Deleting a project enters a 48-hour protective recovery window. Public Content API delivery pauses immediately (returns 404), while all articles and media remain safe in storage. You can Reverse Decision & Restore anytime before the 48-hour deadline expires. Once the deadline passes the project is permanently purged (database records and cloud-storage media) and can no longer be restored.

3. Three Ways to Connect to Murit CMS

Whether integrating manually, vibe-coding with AI models, or executing tools with agents, Murit CMS supports your workflow out of the box:

  • Manual / Framework Fetch: Configure CMS_BASE_URL and CMS_SITE_KEY in your client application, then query public Content API v1 endpoints (such as GET /api/content/v1/sites/:siteKey/posts and /legacy-posts).
  • AI & Vibe Coding: Use our Universal 30-Second Drop-In AI Prompt in Cursor, Windsurf, Claude Code, OpenCode, or Antigravity along with machine-readable specs at /llms.txt.
  • Model Context Protocol (MCP): Connect your AI coding assistant directly to Murit CMS via our built-in stdio MCP server (scripts/mcp-server.ts) to query site info, list posts, fetch articles, and inspect sitemaps in real time.
Need help connecting your own cloud storage? Compare free allowances and follow our step-by-step setup guides: Cloudflare R2, AWS S3, and Google Cloud Storage Connection Guides →

Quickstart

To connect any client site, set two environment variables in your application:

.env.local
CMS_BASE_URL="http://localhost:3000" # 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). All Content API and media routes include Access-Control-Allow-Origin: * headers and respond to HTTP OPTIONS preflights for direct client-side consumption.

GET/api/content/v1/sites/{siteKey}

Returns the public metadata and configuration for the specified site.

200 OK Response
{
  "data": {
    "siteKey": "site_demo_acme",
    "name": "Acme Tech Blog",
    "defaultLocale": "en",
    "primaryURL": "https://acme.com",
    "blogBasePath": "/blogs/",
    "timezone": "UTC",
    "seoDefaults": {}
  }
}
GET/api/content/v1/sites/{siteKey}/posts

Returns a paginated list of published post summaries. Does not include full body HTML for optimal payload size.

ParameterTypeDescription
limitnumber (1-50)Optional. Default is 20.
cursorstringOpaque cursor string for pagination. Pass the nextCursor from previous response.
categorystringOptional slug filter for category.
tagstringOptional slug filter for tag.
200 OK Response Envelope
{
  "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=="
  }
}
GET/api/content/v1/sites/{siteKey}/posts/{slug}

Returns a single published article by slug with full metadata and sanitized contentHtml. If the requested path matches a published post's legacyUrl or redirectUrls entry (absolute permalinks, root-relative paths, and multi-segment paths all resolve), the API answers with an HTTP 308 Permanent Redirect whose Location is the canonical public site URL, plus destination slug metadata in the body.

200 OK Response
{
  "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
      }
    }
  }
}
GET/api/content/v1/sites/{siteKey}/sitemap

Accepts limit (1-500) and cursor. Powers your dynamic sitemap without needing to load bulky article bodies.

GET/api/content/v1/sites/{siteKey}/preview?token={signedToken}

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.

GET/api/content/v1/sites/{siteKey}/legacy-posts

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 & Vimeo embeds, uploaded & streamed videos, Social Embeds (X/Twitter, Instagram, LinkedIn, and Facebook with 50% compact sizing), responsive column grids, image captions and links, and sanitized .content-legacy-html blocks for imported tables, galleries, and buttons), 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.

200 OK Response
{
  "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/"]
    }
  ]
}
POST/api/workspace/migrate

Authenticated endpoint for automated legacy blog migration. Fully supported platforms: WordPress (block and classic editor, via the REST API), Shopify (Admin GraphQL API or public blog feed), and Payload CMS (standard Payload 3 collection endpoints and custom RJLS endpoints), plus Murit/Payload JSON exports. Other JSON feeds are imported on a best-effort basis. Available directly in the Admin Console under the Migrate Legacy Blogs tab on the dedicated project page (/admin?project=<id>&tab=migration) and from 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.

Best-effort migration: importing creates a copy in Murit CMS; the original post on the source site is never modified. Only markup the importer recognises reliably (structure, platform CSS classes, or schema.org metadata) becomes a native block. Everything else is preserved inside a sanitized Legacy HTML block and flagged for review rather than discarded. Detection can still be wrong: review the whole article before publishing, and if you re-edit an imported post without reviewing it, an incorrect or unusual result is possible. Keep a backup of the source site, and remember that re-publishing only changes the Murit copy. Inline images remain hotlinked until media ingestion runs.

HTML Entity Decoding & Editor Sanitization: The migration pipeline and collection hooks automatically sanitize and decode HTML entities (such as &#39;, &quot;, &amp;, &#8217;, &ldquo;, &rdquo;, &ndash;) across post titles, excerpts, Lexical AST rich text nodes, taxonomies, and SEO metadata. Articles appear natural and readable in the Payload CMS editor with genuine quotes and punctuation. Existing database records can be scanned and repaired anytime via npm run repair:entities.

Native Also Read & Social Embed Conversion: Legacy links formatted as "Also Read:", "Read Also:", "Read More:", or <aside class="also-read-link"> are automatically converted into native RelatedArticle blocks and resolved against existing and newly imported website articles. Social media embeds (Twitter/X blockquotes and status URLs, Instagram, LinkedIn, and Facebook embeds) are converted into native SocialEmbed (xEmbed) blocks with server-side metadata enrichment and tracking-safe card previews, while YouTube and Vimeo embeds become native video blocks, Spotify, SoundCloud, Google Maps, TikTok and CodePen embeds become native embed blocks, and notices, alerts, tips, Ghost callout cards and Elementor icon boxes become native callout blocks. For Payload 3 migrations, block aliases (such as twitter, alsoRead, youtube, divider) and legacy upload nodes are automatically normalized to native Murit blocks, while blocks that cannot be represented safely are flattened into native Lexical elements or kept as Legacy HTML so no content is lost and the editor never crashes. This normalization also runs whenever an article is opened or saved in the editor, so supported blocks that older sources stored as plain paragraphs (including Also Read: links and bare social/YouTube/Vimeo URLs) are promoted to editable native blocks instead of remaining text.

Element-accurate HTML conversion: Rendered WordPress and Shopify HTML is parsed as a document tree, so whole elements are recognized before conversion: images (with captions, links, lazy-load and protocol-relative URLs) become externalImage blocks; YouTube, Vimeo (including unlisted privacy hashes) and MP4/WebM videos become video blocks with captions; tweets, Instagram, Facebook and LinkedIn embeds become exactly one xEmbed block each; Gutenberg columns and media & text sections become columns blocks with their widths; separators become horizontal rules; lists and quotes keep their structure; <pre> code becomes an editable code block that keeps its language and renders as <pre><code class="language-{lang}">. Inline links to YouTube stay ordinary links. Complex tables, mixed-content galleries/theme sections, prose-wrapped button groups, forms, plugin widgets and other unsupported markup are kept as a sanitized legacyHtml block with a sandboxed editor preview instead of being flattened to text. A fidelity check compares every visible word and media URL of the source with the result and reports anything that did not carry over.

Migration report: preview and import responses include report ({ articles, cleanArticles, elements: { images, videos, embeds, callouts, buttons, faqs, galleries, tables, socialEmbeds, alsoRead, columns, horizontalRules, legacyHtml }, preserved: { [label]: count }, needsReview: [{ title, slug, postId?, preserved, warnings }] }) and skippedCount (items without a title). import also returns the saved postIds; after the last batch, call { action: "resolve-links", postIds } to link Also Read blocks to articles imported in later batches (returns { linked, updatedPosts }).

Payload FieldTypeDescription
websiteIdnumberTarget website ID (required). Must have site manager access.
action"preview" | "import" | "resolve-links"preview returns article counts, detected categories, and sample cards without saving. import converts and persists posts, taxonomies, and media.
source"wordpress" | "shopify-admin" | "shopify-feed" | "payload" | "json-url" | "json" (optional)Source adapter. Inferred when omitted: an endpointURL containing /wp-json/ uses wordpress; other URLs use json-url; otherwise posts is read. Uploaded WordPress REST and Shopify articles.json objects are detected automatically.
siteURL, pagestring, number (wordpress)WordPress site address (or REST URL) and 1-based page. Reads 25 published posts per page with _embed (featured image, category/tag names, Yoast SEO). Responses include pagination: { page, totalPages, total }.
payloadSiteURL, pagestring, number (payload)Payload CMS site address (or collection URL such as /api/posts or RJLS /rjls-api/posts) and 1-based page. Reads 100 posts per page. Responses include pagination: { page, totalPages, total }.
shopDomain, accessToken, cursorstring (shopify-admin)A *.myshopify.com domain and an Admin API token with the read_content scope (used for the request only, never stored). Reads every published article of every blog, 25 per page, including SEO title/description metafields. Responses include pagination: { nextCursor }.
feedURLstring (shopify-feed)Blog address such as https://store.com/blogs/news. Reads the public Atom feed: recent articles only, without SEO fields or featured images.
endpointURLstring (optional)Remote REST API endpoint returning articles (redirects are followed and re-validated as public destinations).
postsarray (optional)Raw article array or object containing posts array.
MEDIASelf-Serve Media Ingestion & URL Preservation

Legacy Blog Migration provides a 100% self-serve, in-browser media ingestion flow and REST API. After importing articles, tenants can trigger Ingest All Media Assets into Storage with a single click directly inside the Admin Console (no CLI or server access required). Media files are downloaded and uploaded in resilient 5-item batches to prevent community proxy timeouts (Cloudflare 524, Vercel 60s), with real-time progress, pause/resume controls, and automatic 404 skip handling. In CDN mode, ingested inline images are upgraded to native upload cards while keeping their alt text, caption, link, alignment and width; relative rewrite mode keeps the legacy `/wp-content/uploads/...` paths.

Endpoint / ActionMethodDescription
/api/workspace/migrate-media?websiteId=<id>GETAudits legacy media for the website. Returns total discovered, already uploaded, pending count, storage provider label, sample preview, and complete asset lists with source and storage URLs.
/api/workspace/migrate-mediaPOSTExecutes an incremental batch (action: "batch") or audit (action: "audit"). Accepts batchSize (default: 5), rewriteMode ("relative" | "cdn"), and skipUrls.

For zero broken backlinks and 100% SEO rank preservation, choose Same-Origin Exact Path Preservation (relative paths like /wp-content/uploads/photo.webp). Your frontend serves them via a single-line reverse proxy rewrite in astro.config.mjs or next.config.js directly from your own domain.

CLI automation is also supported for headless scripting:

# Pre-flight audit without modifying data
npm run media:migrate -- --website <ID> --dry-run

# Ingest into connected BYOS bucket (R2/S3/GCS) with retry & idempotency
npm run media:migrate -- --website <ID> --rewrite-mode relative
GET/api/workspace/social-preview?url={encodedUrl}&platform={platform}

Authenticated endpoint that fetches server-side metadata and link previews for social posts (LinkedIn, X/Twitter, Instagram, Facebook). Resolves author identity, text content, image assets, and canonical URLs directly on the server to prevent client-side browser tracking prevention blocks (e.g. Microsoft Edge Tracking Prevention or Brave Shields blocking third-party iframe embeds). Cached in-memory with a 10-minute TTL.

GET/api/workspace/resolve-image?url={encodedUrl}

Authenticated endpoint that resolves a pasted link to a direct image URL. Direct image links pass through unchanged; web page links (e.g. an Unsplash photo page) are resolved server-side from their OpenGraph preview image, so authors can insert an external image from either a direct file URL or a page URL. Non-http(s) URLs and private/internal hosts are rejected. The image stays on its original host and is referenced by URL — it is never copied to storage.

POST/api/workspace/websites/{id}/delete-request

Authenticated endpoint for requesting project deletion with a 48-hour grace period timeline. Requires project manager permissions (Platform Admin, Org Admin, or Site Admin). Confirmed by passing either the exact project name or the keyword DELETE in uppercase.

Effects: Sets website status to pending_deletion, records deletionRequestedAt and scheduledForDeletionAt (48 hours from request), and immediately pauses public Content API queries (returns 404). All articles, media, and settings remain safe and intact during the grace period. Once the deadline passes, the purge scheduler permanently deletes the website, its posts, media files (including cloud storage objects), categories, tags, and scoped memberships.

Payload FieldTypeDescription
confirmationTextstringExact project name or the keyword DELETE in capital letters.
POST/api/workspace/websites/{id}/restore

Authenticated endpoint for reversing a project deletion request within the 48-hour grace period. Requires project manager permissions.

Effects: Resets website status to active, clears deletion timestamps, cancels the scheduled purge, and restores immediate live Content API delivery. After the deadline the request is rejected with HTTP 410 and the project is permanently purged instead.

Expired projects are also removed from GET /api/workspace, so they never reappear in the console even if a purge attempt is delayed.

Framework Integration: Next.js (App Router)

Create a modern blog listing page in your Next.js application:

app/blog/page.tsx
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:

app/blog/[slug]/page.tsx
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. Murit 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
---
// src/pages/[...slug].astro (Astro 5 SSG & SSR)
export async function getStaticPaths() {
  const CMS_BASE = import.meta.env.CMS_BASE_URL || 'http://localhost:3000';
  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>

Zero-Dependency TypeScript Helper

Drop this lightweight helper into your project at lib/murit.ts for fully typed Content API access:

lib/murit.ts
const CMS_BASE = process.env.CMS_BASE_URL || 'http://localhost:3000';
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.unpublished, post.deleted). Website settings also offer a Test Webhook Ping, which sends a signed webhook.ping event (postId: "0", slug: "test-ping") so receivers can be verified without publishing. Configure your webhook endpoint in your website settings within the Payload Admin Console.

In Next.js App Router, implement an instant revalidation route handler:

app/api/revalidate/route.ts
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() });
}

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.

Thin Index Database + Cloud JSON Storage Architecture (BYOS)

Murit CMS separates lightweight database indexing from heavy editorial content storage for maximum scale, sub-millisecond query performance, and complete tenant data sovereignty.

Thick JSON in Tenant Cloud Storage

When an article is saved or published, its complete rich-text Lexical AST (content), excerpt, and SEO metadata are serialized directly into the tenant's configured cloud storage bucket (AWS S3, Cloudflare R2, Google Cloud Storage, or platform storage) at sites/{siteKey}/posts/{postId}/thick_content.json. Active drafts are saved into thick_draft.json.

Upon successful cloud persistence, central database rows are automatically thinned out, retaining only essential routing and operational fields (id, title, slug, _status, publishedAt). Body text in the database is replaced with a lightweight stub (EMPTY_LEXICAL_STUB), guaranteeing minimal database storage overhead and total data ownership for tenants.

Public Content API queries (such as GET /api/content/v1/sites/{siteKey}/posts/{slug}, GET /legacy-posts, and draft preview) transparently hydrate the full thick content on demand with zero latency impact and zero API breaking changes for client frontends.

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.

Community Tier & Fair-Use Limits

Murit CMS is built as an open, free community platform for creators and developers. To preserve reliable performance without charging subscription fees, the shared infrastructure adheres to the following fair-use thresholds:

1. Function Execution Duration (60 Seconds)

Content API queries and editorial operations are capped at a maximum execution time of 60 seconds. Standard reads and writes complete in milliseconds, but bulk operations (such as importing large article feeds) must be processed in batches of 200 articles or fewer to prevent execution timeouts.

2. Shared Monthly Quota & Rate Limits

If the community pool reaches its monthly data transfer or serverless quota, or if an individual client exceeds the 120 requests/minute rate limit, the system returns an HTTP 429 status with a clear machine-readable error notice:

{
  "error": {
    "code": "quota_exceeded",
    "message": "Community usage limit reached. Our monthly quota or rate limit has been temporarily reached. Please retry shortly.",
    "requestId": "uuid"
  }
}

Our system never exposes underlying cloud providers, server hostnames, or internal database architectures. If your community organization needs higher dedicated capacity, please reach out directly.

3. Media Storage Quota: Free Starter (200 MB) & BYOS

Every workspace on Murit CMS includes 200 MB of free persistent platform media storage out of the box with zero configuration required, shared across all projects. Uploaded media is served via global edge CDNs with zero bandwidth fees.

Need more media storage? Connect your own Cloudflare R2, AWS S3, MinIO, or Google Cloud Storage bucket using Bring Your Own Storage (BYOS) in Project Settings. When BYOS is active, no platform storage limits apply.

Read the step-by-step Cloudflare R2 & Google Cloud Storage connection guides →

If an organization on the free starter tier reaches the 200 MB shared limit, future media uploads are paused until BYOS is connected or existing files are pruned. Existing blog posts, text editing, and the Content API remain 100% operational.

CMS Performance & Infrastructure Monitoring

Aggregated Media & Query Optimization

Workspace media totals use database aggregates across all matching media records, with one grouped query for the authorized website list. Totals sum original-file sizes and exclude BYOS sites from the shared platform pool; they are not cloud-billing measurements.

Public post-summary listings (GET /posts) and sitemap queries (GET /sitemap) omit article bodies from database reads, maintaining lightweight memory footprints and fast time-to-first-byte (TTFB). Response shapes, permissions, cache headers, and MCP tools remain unchanged.

Database Storage Monitoring (Platform Admins)

Platform administrators see real-time CMS database storage diagnostics on the Overview dashboard. Authenticated GET /api/workspace requests include databaseUsage only for platform admins: status, usedBytes, limitBytes, usagePercent, and checkedAt.

Status thresholds are healthy, warning (80%), critical (90%), urgent (95%), full (100%), or unavailable (null usage). It measures connected-database tables and indexes (including article text, drafts, revisions, and operational indexes); it excludes media files stored in object storage. The default budget is 5,000,000,000 bytes (5 GB); operators can set CMS_DATABASE_STORAGE_LIMIT_BYTES after an infrastructure change. Refresh is manual or every 24 hours while Overview is open.

This is a storage warning, not a publishing block, backup, or billing guarantee. Public Content API and MCP tools do not expose these private metrics.

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!