Skip to content
CMSKite

Making your posts findable

CMSKite stores the content and everything a page needs to describe itself. Your site turns that into HTML. Which half does what matters more here than anywhere else in this API, because every way this goes wrong goes wrong quietly: the blog looks perfect to you and does not exist to a crawler.

Updated

On this page
  1. Render on the server. Everything else depends on it
  2. What goes in the head of each post
  3. Your sitemap, without writing the loop
  4. Structured data, so an assistant can quote you properly
  5. The small things that are already done for you
  6. Five minutes, once

Render on the server. Everything else depends on it

Google will run your JavaScript, eventually, most of the time. Almost nothing else will. The crawlers behind AI assistants, the preview bots behind Slack, WhatsApp, LinkedIn and X, and every tool that reads a page without a browser all take the HTML you serve and stop there. If your posts arrive after the page loads, those readers see an empty shell.

So fetch your posts where the HTML is made: a server component, getStaticProps, a build step, a PHP page, a template. The browser SDK entry point exists for reporting views, not for fetching the article. If you can view source on a post and not find its text, no amount of tags below will help.

What goes in the head of each post

Every post carries an seo block, and every key in it is always present, so you can map it straight into your framework’s metadata without checking whether a field exists. Each one falls back to something sensible: leave a field empty in the dashboard and use the post’s own title and excerpt.

FieldWhat it is forFalls back to
seo.titleThe <title>, and the blue line in a resultpost.title
seo.descriptionThe meta description, and the grey line under itpost.excerpt
seo.canonicalUrlWhich copy counts, when the post exists in two placesthis post’s own address
seo.ogImageThe picture in a link previewpost.featuredMedia.url
seo.noIndexKeep this page out of search resultsfalse
seo.keywordsYour own notes. Search engines have ignored these for two decades[]
Next.js — app/blog/[slug]/page.tsx · ts
export async function generateMetadata({ params }) {
  const { slug } = await params
  const post = await cms.posts.bySlug(slug)
  const url = `https://acme.com/blog/${post.slug}`

  return {
    title: post.seo.title || post.title,
    description: post.seo.description || post.excerpt,
    alternates: { canonical: post.seo.canonicalUrl || url },
    openGraph: {
      type: 'article',
      url,
      title: post.seo.title || post.title,
      description: post.seo.description || post.excerpt,
      images: post.seo.ogImage || post.featuredMedia?.url,
      publishedTime: post.publishedAt,
      modifiedTime: post.updatedAt,
    },
    ...(post.seo.noIndex && { robots: { index: false, follow: true } }),
  }
}

Your sitemap, without writing the loop

GET /v1/blog/sitemap lists every published post with the address it has on your site, the last time it changed, and nothing that is a draft, embargoed, or marked noIndex. The addresses are built from your project’s website and its blog path, which you set once in project settings.

The whole job, if your site can proxy · shell
# /sitemap.xml on your site proxies to:
https://api.cmskite.com/v1/blog/sitemap?format=xml&key=csk_live_…

# Or take JSON and fold it into a sitemap of your own:
curl -H "Authorization: Bearer $CMSKITE_KEY" \
  https://api.cmskite.com/v1/blog/sitemap
Next.js — app/sitemap.ts · ts
export default async function sitemap() {
  const { data } = await fetch(
    'https://api.cmskite.com/v1/blog/sitemap',
    { headers: { Authorization: `Bearer ${process.env.CMSKITE_KEY}` } },
  ).then((r) => r.json())

  return data
    .filter((e) => e.url)
    .map((e) => ({ url: e.url, lastModified: e.lastModified }))
}

Structured data, so an assistant can quote you properly

A block of JSON-LD tells a machine which text is the article, who wrote it and when — rather than leaving it to guess from your markup. It is what turns a result into one with a date and a byline on it, and it is what an AI assistant reads when deciding whether it can attribute an answer to you.

One script tag, on each post page · tsx
const ld = {
  '@context': 'https://schema.org',
  '@type': 'BlogPosting',
  headline: post.seo.title || post.title,
  description: post.seo.description || post.excerpt,
  image: post.seo.ogImage || post.featuredMedia?.url,
  datePublished: post.publishedAt,
  dateModified: post.updatedAt,
  author: { '@type': 'Person', name: post.author?.name ?? 'Acme' },
  mainEntityOfPage: `https://acme.com/blog/${post.slug}`,
}

<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{ __html: JSON.stringify(ld).replace(/</g, '\\u003c') }}
/>

The small things that are already done for you

  • A feed at /v1/blog/feed, in RSS, Atom or JSON, with links that point at your site rather than back at the API.
  • Images compressed, resized and named after their slug, with the alt text you wrote — the only description of a picture a search engine can read.
  • width and height on every image in the response, so your pages reserve the space and do not shift as they load. Layout shift is scored.
  • wordCount and readingMinutes, derived once on our side so every page of yours agrees.
  • Its last 20 slugs are kept, so renaming a post never costs you a link.

Five minutes, once

  1. View source on a live post

    Find a sentence from the middle of the article in the HTML. If it is not there, nothing else on this page matters yet.

  2. Set where posts live

    Project settings → General. /blog/{slug} or whatever yours is.

  3. Map the seo block into your metadata

    Title, description, canonical, image, noIndex. The snippet above is the whole of it.

  4. Point your sitemap at ours

    Then submit it once in Google Search Console.

  5. Add the JSON-LD block

    And check one post in Google’s Rich Results Test.