Search documentation

Find pages, sections, and snippets across the Clog docs.

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:

  1. seo — stamp <title> / <meta> / <link rel="canonical"> / OG / Twitter tags in <head>.
  2. structuredBlocks (a derived projection of structuredData) — render the recipe card, how-to steps, event banner, or product info with the same block renderer you already use for bodyJson.
  3. 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.google into <meta name="google-site-verification" content="...">.
  • Default OG image fallback when an entity's seo.og.imageMediaId isn't set.
  • Open Graph site nameog:site_name from siteName.
  • 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:

FieldShapeWhat you do with it
structuredDataobject, keyed by schemaTypeBuild a custom widget (e.g. a Recipe ingredients side-card) if you want bespoke rendering.
structuredBlocksBlockView[] — same shape as bodyJsonRender 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.
jsonLdSchema.org @graphStamp 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., per schemaType / pageType).
  • The publisher Organization node, sourced from the workspace's publisher block.
  • A BreadcrumbList node, when breadcrumbsEnabled and a breadcrumb trail is available (e.g. a post in a nested category).
  • Embedded Person, Place, Product, or MediaObject nodes 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/:idOrSlugCollectionPage jsonLd + breadcrumb.
  • GET /external/tags/:idOrSlugCollectionPage jsonLd.
  • GET /external/authors/:idOrSlugProfilePage jsonLd wrapping an embedded Person node.

Treat them like miniature posts when rendering the listing pages on your site (e.g. /categories/<slug> and /authors/<slug>).

On this page