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.