Claude Code for Ghost: Headless Publishing Platform — Claude Skills 360 Blog
Blog / Backend / Claude Code for Ghost: Headless Publishing Platform
Backend

Claude Code for Ghost: Headless Publishing Platform

Published: May 31, 2027
Read time: 6 min read
By: Claude Skills 360

Ghost is an open-source headless publishing platform — new GhostContentAPI({ url, key, version: "v5.0" }) creates the read-only content client. api.posts.browse({ limit: 10, include: "tags,authors", filter: "featured:true" }) fetches posts. api.posts.read({ slug }) fetches a single post by slug. Each post has html, feature_image, excerpt, primary_author, tags, reading_time, and published_at. api.pages.browse() fetches static pages. api.tags.browse() and api.authors.browse() list taxonomy. Admin API: new GhostAdminAPI({ url, key, version: "v5.0" })adminApi.posts.add({ title, html, status: "published" }) creates posts programmatically. Ghost webhooks fire on post.published, post.updated, member.created. Ghost supports native newsletters with subscribers, members with paid tiers via Stripe, and comment notifications. Claude Code generates Ghost content clients, blog pages with ISR, tag archives, and webhook handlers.

CLAUDE.md for Ghost

## Ghost Stack
- Version: @tryghost/content-api >= 1.11, @tryghost/admin-api >= 1.14
- Content client: const api = new GhostContentAPI({ url: GHOST_URL, key: GHOST_CONTENT_API_KEY, version: "v5.0" })
- Posts: const posts = await api.posts.browse({ limit: 10, include: "tags,authors", filter: "visibility:public" })
- Single post: const post = await api.posts.read({ slug }, { include: "tags,authors" })
- Admin: const admin = new GhostAdminAPI({ url: GHOST_URL, key: GHOST_ADMIN_API_KEY, version: "v5.0" })
- Revalidate: export const revalidate = 60 — ISR for Ghost content

Ghost Content Client

// lib/ghost/client.ts — Ghost SDK setup and typed helpers
import GhostContentAPI from "@tryghost/content-api"
import GhostAdminAPI from "@tryghost/admin-api"

const GHOST_URL = process.env.GHOST_URL!
const GHOST_CONTENT_KEY = process.env.GHOST_CONTENT_API_KEY!
const GHOST_ADMIN_KEY = process.env.GHOST_ADMIN_API_KEY!

export const ghostContent = new GhostContentAPI({
  url: GHOST_URL,
  key: GHOST_CONTENT_KEY,
  version: "v5.0",
})

export const ghostAdmin = new GhostAdminAPI({
  url: GHOST_URL,
  key: GHOST_ADMIN_KEY,
  version: "v5.0",
})

// ── Type definitions ───────────────────────────────────────────────────────

export type GhostPost = {
  id: string
  uuid: string
  title: string
  slug: string
  html: string | null
  excerpt: string | null
  custom_excerpt: string | null
  feature_image: string | null
  feature_image_alt: string | null
  published_at: string | null
  updated_at: string | null
  reading_time: number
  featured: boolean
  visibility: "public" | "members" | "paid"
  tags: GhostTag[]
  authors: GhostAuthor[]
  primary_author: GhostAuthor | null
  primary_tag: GhostTag | null
  og_title: string | null
  og_description: string | null
  twitter_title: string | null
  meta_title: string | null
  meta_description: string | null
  url: string
}

export type GhostTag = {
  id: string
  name: string
  slug: string
  description: string | null
  feature_image: string | null
  meta_title: string | null
  meta_description: string | null
  url: string
}

export type GhostAuthor = {
  id: string
  name: string
  slug: string
  bio: string | null
  profile_image: string | null
  website: string | null
  twitter: string | null
  location: string | null
  url: string
}

// ── Post queries ───────────────────────────────────────────────────────────

export async function getAllPosts(options: {
  limit?: number
  page?: number
  filter?: string
  order?: string
} = {}): Promise<{ posts: GhostPost[]; meta: any }> {
  const posts = await ghostContent.posts.browse({
    limit: options.limit ?? 10,
    page: options.page ?? 1,
    include: ["tags", "authors"],
    filter: options.filter ?? "visibility:public",
    order: options.order ?? "published_at DESC",
    fields: undefined,  // Fetch all fields
  }) as GhostPost[]

  return { posts, meta: (posts as any).meta }
}

export async function getPost(slug: string): Promise<GhostPost | null> {
  try {
    const post = await ghostContent.posts.read(
      { slug },
      { include: ["tags", "authors"] },
    ) as GhostPost
    return post
  } catch {
    return null
  }
}

export async function getFeaturedPosts(limit = 3): Promise<GhostPost[]> {
  const posts = await ghostContent.posts.browse({
    limit,
    filter: "featured:true+visibility:public",
    include: ["tags", "authors"],
    order: "published_at DESC",
  }) as GhostPost[]
  return posts
}

export async function getPostsByTag(tagSlug: string, limit = 10): Promise<GhostPost[]> {
  const posts = await ghostContent.posts.browse({
    limit,
    filter: `tag:${tagSlug}+visibility:public`,
    include: ["tags", "authors"],
    order: "published_at DESC",
  }) as GhostPost[]
  return posts
}

export async function getRelatedPosts(post: GhostPost, limit = 3): Promise<GhostPost[]> {
  if (!post.primary_tag) return []

  const posts = await ghostContent.posts.browse({
    limit: limit + 1,
    filter: `tag:${post.primary_tag.slug}+visibility:public+id:-${post.id}`,
    include: ["tags", "authors"],
    order: "published_at DESC",
  }) as GhostPost[]

  return posts.slice(0, limit)
}

// ── Tag and author queries ──────────────────────────────────────────────────

export async function getAllTags(): Promise<GhostTag[]> {
  return ghostContent.tags.browse({
    limit: "all",
    include: ["count.posts"],
    filter: "visibility:public",
  }) as Promise<GhostTag[]>
}

export async function getAllAuthors(): Promise<GhostAuthor[]> {
  return ghostContent.authors.browse({
    limit: "all",
    include: ["count.posts"],
  }) as Promise<GhostAuthor[]>
}

export async function getAllSlugs(): Promise<string[]> {
  const posts = await ghostContent.posts.browse({
    limit: "all",
    filter: "visibility:public",
    fields: ["slug"],
  }) as { slug: string }[]
  return posts.map(p => p.slug)
}

Next.js Blog Pages

// app/blog/page.tsx — Ghost blog listing with ISR
import { getAllPosts } from "@/lib/ghost/client"
import { PostCard } from "@/components/blog/PostCard"
import type { Metadata } from "next"

export const metadata: Metadata = {
  title: "Blog",
  description: "Latest articles and updates",
}

export default async function BlogPage({
  searchParams,
}: {
  searchParams: { page?: string }
}) {
  const page = Number(searchParams.page) || 1
  const { posts, meta } = await getAllPosts({ limit: 12, page })

  return (
    <div className="max-w-6xl mx-auto px-4 py-12">
      <div className="mb-12">
        <h1 className="text-4xl font-bold mb-2">Blog</h1>
        <p className="text-muted-foreground">Articles, guides, and updates.</p>
      </div>

      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {posts.map(post => (
          <PostCard key={post.id} post={post} />
        ))}
      </div>

      {/* Pagination */}
      {meta?.pagination && meta.pagination.pages > 1 && (
        <div className="flex justify-center gap-2 mt-12">
          {Array.from({ length: meta.pagination.pages }, (_, i) => i + 1).map(p => (
            <a
              key={p}
              href={`/blog?page=${p}`}
              className={`px-4 py-2 rounded-lg text-sm border transition-colors ${
                p === page
                  ? "bg-primary text-primary-foreground border-primary"
                  : "hover:bg-muted"
              }`}
            >
              {p}
            </a>
          ))}
        </div>
      )}
    </div>
  )
}

export const revalidate = 60  // ISR: refresh every minute
// app/blog/[slug]/page.tsx — Ghost post detail with ISR
import { getPost, getRelatedPosts, getAllSlugs } from "@/lib/ghost/client"
import { notFound } from "next/navigation"
import type { Metadata } from "next"

export async function generateStaticParams() {
  const slugs = await getAllSlugs()
  return slugs.map(slug => ({ slug }))
}

export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
  const post = await getPost(params.slug)
  if (!post) return {}

  return {
    title: post.meta_title ?? post.title,
    description: post.meta_description ?? post.excerpt ?? undefined,
    openGraph: {
      title: post.og_title ?? post.title,
      description: post.og_description ?? post.excerpt ?? undefined,
      images: post.feature_image ? [{ url: post.feature_image }] : undefined,
    },
  }
}

export default async function BlogPostPage({ params }: { params: { slug: string } }) {
  const [post, related] = await Promise.all([
    getPost(params.slug),
    getPost(params.slug).then(p => p ? getRelatedPosts(p) : []),
  ])

  if (!post) notFound()

  return (
    <article className="max-w-3xl mx-auto px-4 py-12">
      {/* Header */}
      <header className="mb-8">
        {post.primary_tag && (
          <a
            href={`/blog/tag/${post.primary_tag.slug}`}
            className="text-sm font-medium text-primary mb-3 block"
          >
            {post.primary_tag.name}
          </a>
        )}
        <h1 className="text-4xl font-bold tracking-tight mb-4">{post.title}</h1>

        {(post.custom_excerpt ?? post.excerpt) && (
          <p className="text-xl text-muted-foreground">
            {post.custom_excerpt ?? post.excerpt}
          </p>
        )}

        <div className="flex items-center gap-4 mt-6 text-sm text-muted-foreground">
          {post.primary_author && (
            <div className="flex items-center gap-2">
              {post.primary_author.profile_image && (
                <img
                  src={post.primary_author.profile_image}
                  alt={post.primary_author.name}
                  className="size-7 rounded-full object-cover"
                />
              )}
              <span>{post.primary_author.name}</span>
            </div>
          )}
          {post.published_at && (
            <time dateTime={post.published_at}>
              {new Date(post.published_at).toLocaleDateString("en-US", {
                year: "numeric", month: "long", day: "numeric",
              })}
            </time>
          )}
          <span>{post.reading_time} min read</span>
        </div>
      </header>

      {/* Feature image */}
      {post.feature_image && (
        <img
          src={post.feature_image}
          alt={post.feature_image_alt ?? post.title}
          className="w-full aspect-video object-cover rounded-2xl mb-10"
        />
      )}

      {/* Ghost HTML content */}
      <div
        className="prose prose-neutral dark:prose-invert max-w-none gh-content"
        dangerouslySetInnerHTML={{ __html: post.html ?? "" }}
      />

      {/* Tags */}
      {post.tags.length > 0 && (
        <div className="flex flex-wrap gap-2 mt-10 pt-6 border-t">
          {post.tags.map(tag => (
            <a
              key={tag.id}
              href={`/blog/tag/${tag.slug}`}
              className="px-3 py-1 rounded-full text-xs bg-muted hover:bg-muted/80 transition-colors"
            >
              #{tag.name}
            </a>
          ))}
        </div>
      )}
    </article>
  )
}

export const revalidate = 60

Ghost Webhook Handler

// app/api/webhooks/ghost/route.ts — revalidate on post publish
import { NextRequest, NextResponse } from "next/server"
import { revalidatePath, revalidateTag } from "next/cache"
import crypto from "crypto"

export async function POST(request: NextRequest) {
  // Verify Ghost webhook secret
  const signature = request.headers.get("x-ghost-signature") ?? ""
  const rawBody = await request.text()

  const [hashPart, tsPart] = signature.split(", ")
  const hash = hashPart?.replace("sha256=", "")
  const ts = tsPart?.replace("t=", "")

  const expectedHash = crypto
    .createHmac("sha256", process.env.GHOST_WEBHOOK_SECRET!)
    .update(`${rawBody}${ts}`)
    .digest("hex")

  if (hash !== expectedHash) {
    return new Response("Invalid signature", { status: 401 })
  }

  const event = JSON.parse(rawBody)
  const postSlug = event.post?.current?.slug ?? event.post?.previous?.slug

  // Revalidate affected pages
  revalidatePath("/blog")
  if (postSlug) {
    revalidatePath(`/blog/${postSlug}`)
  }
  revalidateTag("ghost-posts")

  return NextResponse.json({ revalidated: true })
}

For the Storyblok alternative when a more visual page builder with block-based content, live preview in the visual editor, and non-developer-friendly nested component composition is needed — Storyblok has a drag-and-drop editor while Ghost is focused on writers and editorial workflows with native newsletters and memberships, see the Storyblok guide. For the Contentful alternative when a structured content model, multi-locale support, and enterprise-grade content governance with roles and workflows is required — Contentful scales better for complex content models across teams while Ghost is focused on the publishing and monetization workflow, see the Contentful guide. The Claude Skills 360 bundle includes Ghost skill sets covering content API, ISR, and webhook revalidation. Start with the free tier to try Ghost headless CMS generation.

Keep Reading

Backend

Claude Code for Bun: Fast JavaScript Runtime and Toolkit

Build with Bun and Claude Code — Bun.serve for HTTP servers, Bun.file for fast file I/O, Bun.$ for shell commands, Bun.sql for SQLite and PostgreSQL, Bun.build for bundling, bun:test for testing, Bun.hash for hashing, bun.lock for deterministic installs, bun run for package.json scripts, hot reloading with --hot, bun init for project scaffolding, and compatibility with Node.js modules.

6 min read Jun 13, 2027
Backend

Claude Code for Express.js Advanced: Patterns for Production APIs

Advanced Express.js patterns with Claude Code — typed request handlers with RequestHandler generics, async error handling middleware, Zod validation middleware factory, rate limiting with express-rate-limit and Redis store, helmet security middleware, compression, dependency injection with tsyringe, file upload with multer and S3, pagination utilities, JWT middleware, and structured logging with pino.

6 min read Jun 8, 2027
Backend

Claude Code for KeystoneJS: Node.js CMS and App Framework

Build full-stack apps with KeystoneJS and Claude Code — config with lists, fields.text and fields.relationship for schema definition, access control with isAuthenticated and isAdmin functions, hooks with beforeOperation and afterOperation, GraphQL API auto-generation from schema, AdminUI for content management, session with statelessSessions, Prisma adapter for database, file storage with images and files fields, and custom REST endpoints.

6 min read Jun 7, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free