Start here

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 September 29, 2026

4 min read

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.

This is the one we cannot check for you. Integration health can tell you that your key works and that your site is calling us. It cannot tell whether what you did with the answer ended up in the HTML. Right-click a live post, choose View Source, and search for a sentence from the middle of it.

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.getBySlug(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 } }),
  }
}
A renamed post keeps its inbound links. Change a slug and the old one keeps answering. The response then carries slugRedirectedFrom — when it is not null, answer your own visitor with a 301 to post.slug. Skip it and you publish two addresses for one article, which is the one SEO mistake that costs you rankings you already had.

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 }))
}
Set “where posts live” first. Until you set it, we assume a post is at /<slug>. If your blog is at /blog/<slug>, every address in your sitemap, your feed and your canonical tags is one a crawler will get a 404 from. It is one field in Project settings → General.

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') }}
/>
Escape the angle brackets. A JSON-LD block is still a script tag. A post body containing </script> ends it early and breaks the page, which is why the replace above is not optional.

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.