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
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.
| Field | What it is for | Falls back to |
|---|---|---|
| seo.title | The <title>, and the blue line in a result | post.title |
| seo.description | The meta description, and the grey line under it | post.excerpt |
| seo.canonicalUrl | Which copy counts, when the post exists in two places | this post’s own address |
| seo.ogImage | The picture in a link preview | post.featuredMedia.url |
| seo.noIndex | Keep this page out of search results | false |
| seo.keywords | Your own notes. Search engines have ignored these for two decades | [] |
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.
# /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/sitemapexport 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.
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
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.
Set where posts live
Project settings → General. /blog/{slug} or whatever yours is.
Map the seo block into your metadata
Title, description, canonical, image, noIndex. The snippet above is the whole of it.
Point your sitemap at ours
Then submit it once in Google Search Console.
Add the JSON-LD block
And check one post in Google’s Rich Results Test.