SEO and JSON-LD
Render the SEO meta group into your head, project structuredData into rendered blocks, and stamp the pre-assembled jsonLd graph for crawlers.
Clog ships a Yoast/RankMath-grade SEO surface across every content entity. Once a post is authored, the read response gives you three ways to surface its SEO and structured content from a single source: the typed seo meta group, the type-specific structuredData, and a pre-assembled jsonLd Schema.org graph.
You'll typically use all three:
seo— stamp<title>/<meta>/<link rel="canonical">/ OG / Twitter tags in<head>.structuredBlocks(a derived projection ofstructuredData) — render the recipe card, how-to steps, event banner, or product info with the same block renderer you already use forbodyJson.jsonLd— drop into a<script type="application/ld+json">tag for search crawlers.
Data is entered once in the dashboard and served three ways from one source.
The seo meta group
Every content entity (Post, Page, BlogCategory, Tag, Author) carries an seo object of the same shape:
type SeoCommon = {
title?: string;
description?: string;
canonicalUrl?: string;
focusKeyword?: string;
secondaryKeywords?: string[]; // up to 5
robots: {
index: boolean; // default true
follow: boolean; // default true
archive: boolean; // default true
imageIndex: boolean; // default true
snippet: boolean; // default true
maxSnippet?: number;
maxImagePreview?: 'none' | 'standard' | 'large';
maxVideoPreview?: number;
};
og: {
title?: string;
description?: string;
imageMediaId?: string;
type?: string;
};
twitter: {
card?: 'summary' | 'summary_large_image' | 'player' | 'app';
title?: string;
description?: string;
imageMediaId?: string;
creator?: string;
};
breadcrumbsTitle?: string;
noindexReason?: string;
redirectTo?: string;
};Every nested object is always present in the response — robots, og, and twitter default to their populated defaults if the author didn't set anything. Unknown keys are stripped server-side.
Rendering it into <head>
The title and description may be templated at the workspace level (e.g. "%title% %sep% %sitename%"); they arrive fully resolved on the read response, so you can stamp them directly.
function SeoTags({ seo, post }: { seo: SeoCommon; post: PostResponse }) {
const title = seo.title ?? post.title;
const description = seo.description ?? post.excerpt ?? '';
return (
<>
<title>{title}</title>
<meta name="description" content={description} />
{seo.canonicalUrl && <link rel="canonical" href={seo.canonicalUrl} />}
<meta name="robots" content={[
seo.robots.index ? 'index' : 'noindex',
seo.robots.follow ? 'follow' : 'nofollow',
!seo.robots.archive && 'noarchive',
!seo.robots.imageIndex && 'noimageindex',
!seo.robots.snippet && 'nosnippet',
seo.robots.maxSnippet != null && `max-snippet:${seo.robots.maxSnippet}`,
seo.robots.maxImagePreview && `max-image-preview:${seo.robots.maxImagePreview}`,
seo.robots.maxVideoPreview != null && `max-video-preview:${seo.robots.maxVideoPreview}`,
].filter(Boolean).join(', ')} />
{seo.og.title && <meta property="og:title" content={seo.og.title} />}
{seo.og.description && <meta property="og:description" content={seo.og.description} />}
{seo.og.type && <meta property="og:type" content={seo.og.type} />}
{seo.twitter.card && <meta name="twitter:card" content={seo.twitter.card} />}
{seo.twitter.creator && <meta name="twitter:creator" content={seo.twitter.creator} />}
</>
);
}og.imageMediaId / twitter.imageMediaId are media-row references, not URLs. To stamp the actual <meta property="og:image">, resolve the media URL via GET /external/media/:id — or use the workspace-level defaultOgImageUrl from GET /external/workspace as your fallback.
Workspace SEO defaults and publisher info
Workspace-level fields drive defaults across every entity and the publisher Organization in jsonLd. Pull them once per build via GET /external/workspace:
{
"id": "...",
"name": "Acme Blog",
"slug": "acme",
"siteName": "Acme Blog",
"siteUrl": "https://acme.example.com",
"defaultLocale": "en-US",
"twitterSite": "@acme",
"facebookAppId": "1234567890",
"breadcrumbsEnabled": true,
"siteVerification": {
"google": "abc...",
"bing": "def..."
},
"defaultOgImageUrl": "https://storage.example.com/.../default-og.jpg",
"publisherType": "Organization",
"publisher": {
"name": "Acme Inc.",
"logoMediaId": "...",
"sameAs": ["https://twitter.com/acme", "https://github.com/acme"]
},
"primaryColor": "#8b5cf6",
"logoUrl": "https://storage.example.com/.../logo.png"
}Use it for:
- Site verification tags — drop
siteVerification.googleinto<meta name="google-site-verification" content="...">. - Default OG image fallback when an entity's
seo.og.imageMediaIdisn't set. - Open Graph site name —
og:site_namefromsiteName. - Brand surfaces — your header logo, theme color, "About" link to the workspace's
aboutMd.
structuredData and structuredBlocks
Posts carry an indexed schemaType field (one of the ~50-variant Schema.org catalogue: BlogPosting, Recipe, HowTo, Event, Product, Review, ...) and a structuredData object of fields specific to that type — recipe ingredients, how-to steps, event dates, product offers, and so on.
structuredData is reader-facing content, not just metadata. The API delivers it three ways:
| Field | Shape | What you do with it |
|---|---|---|
structuredData | object, keyed by schemaType | Build a custom widget (e.g. a Recipe ingredients side-card) if you want bespoke rendering. |
structuredBlocks | BlockView[] — same shape as bodyJson | Render with the same block renderer you already use for the post body. The backend has projected the recipe / how-to / etc. into the 13-block vocabulary. |
jsonLd | Schema.org @graph | Stamp into a <script type="application/ld+json"> for crawlers. |
The block-renderer reuse pattern
This is the highest-value primitive in the SEO system. For a recipe post:
function RecipePost({ post }: { post: PostResponse }) {
return (
<article>
<h1>{post.title}</h1>
{/* The author-written intro / story body */}
{post.bodyJson.map(renderBlock)}
{/* The recipe card — ingredients, instructions, nutrition — rendered by the SAME block renderer */}
{post.structuredBlocks.length > 0 && (
<section aria-label="Recipe">
{post.structuredBlocks.map(renderBlock)}
</section>
)}
</article>
);
}No bespoke <Recipe> component required. The backend translates each schemaType into the appropriate sequence of heading, list, paragraph, image, key_takeaways, faq, etc. blocks — and your existing renderer (from Rendering content) covers them.
structuredBlocks is image-URL-enriched just like bodyJson.
When structuredData is empty
A post without structured content (e.g. a plain BlogPosting) ships structuredData: {} and structuredBlocks: []. Just check before rendering:
{post.structuredBlocks.length > 0 && <RecipeCard blocks={post.structuredBlocks} />}jsonLd — the crawler view
Every content read response (/external/posts/:idOrSlug, /external/pages/:idOrSlug, /external/categories/:idOrSlug, /external/tags/:idOrSlug, /external/authors/:idOrSlug) carries a jsonLd field: a fully-assembled Schema.org @graph ready to drop into a <script> tag.
The graph includes:
- The entity itself (
BlogPosting,Recipe,HowTo,Event,ProfilePage, etc., perschemaType/pageType). - The publisher
Organizationnode, sourced from the workspace'spublisherblock. - A
BreadcrumbListnode, whenbreadcrumbsEnabledand a breadcrumb trail is available (e.g. a post in a nested category). - Embedded
Person,Place,Product, orMediaObjectnodes as the type requires.
You don't assemble any of this client-side. You stamp it.
Stamping jsonLd
In Next.js App Router:
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await fetchPost(params.slug);
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(post.jsonLd) }}
/>
<article>
<h1>{post.title}</h1>
{post.bodyJson.map(renderBlock)}
</article>
</>
);
}In plain HTML:
<script type="application/ld+json">
{ /* paste post.jsonLd here */ }
</script>Stamp jsonLd as raw JSON — don't HTML-escape it. The <script type="application/ld+json"> tag is its own parser context. Use JSON.stringify (or your renderer's equivalent) and inject the string verbatim.
Validate occasionally with the Google Rich Results Test or Schema.org's validator — useful for catching authoring mistakes in your dashboard before they go live.
SEO scores
Posts carry two optional 0–100 integers:
seoScore— keyword presence, title/meta lengths, internal/external links, image alt coverage.readabilityScore— Flesch–Kincaid, sentence length distribution, passive-voice ratio, transition-word coverage, paragraph length.
Both are recomputed server-side on every write. You don't need to display them on the public site — they're for editorial workflows in the dashboard. You can filter and sort the list endpoint by them (orderBy=seoScore / minSeoScore / maxSeoScore query params) to surface drafts that need attention from a dashboard widget you build yourself.
Sitemap data
The GET /external/feeds/sitemap-data endpoint returns one row per published post and page:
{
"data": [
{ "url": "/posts/my-first-post", "lastmod": "2026-05-23T12:34:56.789Z", "changefreq": "weekly", "priority": 0.7 },
{ "url": "/pages/about", "lastmod": "2026-05-12T09:00:00.000Z", "changefreq": "monthly", "priority": 0.5 }
]
}Prepend your site's origin to each url and stamp the XML yourself — Clog does not host an XML sitemap. The endpoint requires posts:read.
Per-entity SEO routes
Categories, Tags, and Authors also ship seo + jsonLd on their read responses. Pattern:
GET /external/categories/:idOrSlug→CollectionPagejsonLd+ breadcrumb.GET /external/tags/:idOrSlug→CollectionPagejsonLd.GET /external/authors/:idOrSlug→ProfilePagejsonLdwrapping an embeddedPersonnode.
Treat them like miniature posts when rendering the listing pages on your site (e.g. /categories/<slug> and /authors/<slug>).
Related
- Rendering content — the block renderer
structuredBlocksreuses. - Reference → Block types — full per-block schemas.
- Examples → Next.js — a worked App Router integration that stamps
seo+jsonLdand rendersstructuredBlocks.