Install guides

Add a blog to a Next.js site

Two pages in the App Router: a list at /blog and a post at /blog/[slug]. Both render on the server, so every post is in the HTML that Google reads. About twenty minutes.

Updated 26 October 2026

1 min read

Before you start

  • A Next.js app on the App Router
  • A project API key from Developer → API keys. It is read-only and serves published posts only
  • At least one published post

Install and connect

bash
npm install cmskite marked

.env.local

bash
CMSKITE_API_KEY=csk_live_...

lib/cms.ts

ts
import { createCMSKite } from 'cmskite'

export const cms = createCMSKite({ apiKey: process.env.CMSKITE_API_KEY! })

The key has no NEXT_PUBLIC_ prefix, so it stays on the server. Use the prefixed name only for code that runs in the browser.

The list page

app/blog/page.tsx

tsx
import Link from 'next/link'
import { cms } from '@/lib/cms'

export default async function Blog() {
  const { items } = await cms.posts.list({ limit: 20 })
  return (
    <ul>
      {items.map((post) => (
        <li key={post.id}>
          <Link href={`/blog/${post.slug}`}>{post.title}</Link>
          <p>{post.excerpt}</p>
        </li>
      ))}
    </ul>
  )
}

The post page

app/blog/[slug]/page.tsx

tsx
import { marked } from 'marked'
import { notFound, redirect } from 'next/navigation'
import { cms } from '@/lib/cms'

export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await cms.posts.findBySlug(slug)
  if (!post) notFound()
  // An old address: send readers and search engines to the new one.
  if (post.slugRedirectedFrom) redirect(`/blog/${post.slug}`)
  const html = post.bodyFormat === 'markdown' ? await marked.parse(post.body) : post.body
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: html }} />
    </article>
  )
}

Titles, descriptions and the canonical address for generateMetadata are in Making your posts findable.

Show new posts without a rebuild

If your pages are cached, a new post waits for the cache to expire. Instead, add a webhook for post.published and post.updated that calls a route which runs revalidatePath('/blog'). The post is live seconds after you press Publish.

Count views

Server-rendered pages do not report views by themselves. Add the three-line hook from Why your views are zero.

Check that it works

  1. Publish a short test post in the dashboard
  2. Open /blog on your deployed site in a private window and reload. The post should be in the list within a minute
  3. Open the post. Its address should be your site's, not cmskite.com
  4. Delete or unpublish the test post when you are done