Ghost CMS + Next.js: the headless setup I use for every client
Ghost is built to be a publishing app you log into and write in. Next.js is built to render whatever data source you point at it. Wire them together and you get a publishing experience your client (or future-you) actually wants to use, with all the speed and SEO benefits of static-first Next.
Why Ghost, not Sanity / Contentlayer / MDX
I've shipped sites on Sanity (good schema, terrible writing UX), Contentlayer (dead now), MDX-in-git (great for engineers, useless for non-engineers), and Ghost. Ghost wins for content-first sites because:
- Writers actually use it. The editor is closer to Medium / Notion than to Markdown.
- Newsletter built in. Members + email comes free. Your client doesn't need to bolt on Mailchimp.
- Content API is fast and stable. One endpoint, JWT-free, public-readable, well-cached.
- Self-hostable. $12 droplet alongside your other stuff.
- SEO is opinionated and good. OG tags, sitemap, RSS, AMP — out of the box.
It loses to Sanity when you need structured non-blog content (product catalogs, real schemas, deeply nested references). For "marketing site + blog + newsletter" — Ghost.
The setup, end to end
1. Ghost side: create an integration
In Ghost admin → Settings → Integrations → Add custom integration → name it "Next site". You get two keys:
- Content API key — public, safe in browser if needed. Used for reading posts.
- Admin API key — server-only. Used for member subscriptions, programmatic publishing.
2. Next.js side: env vars
# .env.local
NEXT_PUBLIC_GHOST_URL=https://ghost.yourdomain.com
NEXT_PUBLIC_GHOST_CONTENT_API_KEY=fdfa...
GHOST_ADMIN_API_KEY=id:secret # for newsletter signups (server-side)3. The Ghost lib (the only file you really need)
Plain fetch() — no @tryghost/content-api package needed. Saves ~30 KB on your bundle.
// src/lib/ghost.ts
const GHOST_URL = process.env.NEXT_PUBLIC_GHOST_URL || '';
const GHOST_KEY = process.env.NEXT_PUBLIC_GHOST_CONTENT_API_KEY || '';
export interface BlogPost {
slug: string;
title: string;
date: string;
excerpt: string;
contentHtml: string;
coverImage: string;
readingTime: string;
tags: string[];
}
async function ghostFetch<T>(endpoint: string, params: Record<string,string> = {}): Promise<T | null> {
if (!GHOST_URL || !GHOST_KEY) return null;
const qs = new URLSearchParams({
key: GHOST_KEY,
include: 'tags,authors',
formats: 'html',
...params,
}).toString();
const res = await fetch(`${GHOST_URL}/ghost/api/content/${endpoint}?${qs}`, {
next: { revalidate: 300 }, // ← cache 5 min
});
if (!res.ok) return null;
return res.json() as Promise<T>;
}
export async function getPosts(): Promise<BlogPost[]> {
const data = await ghostFetch<{ posts: any[] }>('posts', { limit: 'all', order: 'published_at desc' });
return (data?.posts || []).map(transform);
}
export async function getPost(slug: string): Promise<BlogPost | null> {
const data = await ghostFetch<{ posts: any[] }>(`posts/slug/${encodeURIComponent(slug)}`);
return data?.posts?.[0] ? transform(data.posts[0]) : null;
}
function transform(g: any): BlogPost {
return {
slug: g.slug,
title: g.title,
date: g.published_at.slice(0, 10),
excerpt: g.custom_excerpt || g.excerpt || '',
contentHtml: g.html,
coverImage: g.feature_image || '/placeholder.jpg',
readingTime: `${g.reading_time || 5} min read`,
tags: (g.tags || []).map((t: any) => t.name),
};
}4. The blog page (server component)
// src/app/blog/page.tsx
import { getPosts } from '@/lib/ghost';
import BlogList from './BlogList'; // client component for search/filter
export const revalidate = 300;
export default async function BlogPage() {
const posts = await getPosts();
return <BlogList posts={posts} />;
}5. The post page (ISR + static params)
// src/app/blog/[slug]/page.tsx
import { getPost, getPosts } from '@/lib/ghost';
import { notFound } from 'next/navigation';
export const revalidate = 300;
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map(p => ({ slug: p.slug }));
}
export default async function BlogPostPage({ params }: { params: Promise<{slug: string}> }) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return (
<article className="prose dark:prose-invert max-w-none">
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
</article>
);
}The trick I always add: static fallback
What happens when Ghost is down or you're running locally without keys? You get an empty blog index. Bad UX. Fix: bake in a small static fallback array.
import { blogPosts as STATIC_FALLBACK } from '@/data/blog-posts';
export async function getPosts() {
const data = await ghostFetch...;
const ghostPosts = (data?.posts || []).map(transform);
if (!ghostPosts.length) return STATIC_FALLBACK;
// Merge — Ghost wins on slug collisions
const seen = new Set(ghostPosts.map(p => p.slug));
return [
...ghostPosts,
...STATIC_FALLBACK.filter(p => !seen.has(p.slug)),
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}This means:
- Ghost down → site still loads with seed posts.
- Ghost has 0 posts → site still has content.
- Ghost has 5 posts that collide with seeds → Ghost wins, no duplicates.
- Local dev without keys → seeds render, you can style + ship the listing UX.
Newsletter signup via Ghost Members API
The Ghost Admin API has a /members endpoint. Pop email in → Ghost handles confirmation + double-opt-in.
// src/app/api/subscribe/route.ts (server-only)
import crypto from 'node:crypto';
function ghostJwt(apiKey: string) {
const [id, secretHex] = apiKey.split(':');
const secret = Buffer.from(secretHex, 'hex');
const header = { alg: 'HS256', typ: 'JWT', kid: id };
const now = Math.floor(Date.now() / 1000);
const payload = { iat: now, exp: now + 300, aud: '/admin/' };
const b64 = (o: object) => Buffer.from(JSON.stringify(o)).toString('base64url');
const head = b64(header);
const body = b64(payload);
const sig = crypto.createHmac('sha256', secret).update(`${head}.${body}`).digest('base64url');
return `${head}.${body}.${sig}`;
}
export async function POST(req: Request) {
const { email } = await req.json();
const token = ghostJwt(process.env.GHOST_ADMIN_API_KEY!);
const res = await fetch(`${process.env.GHOST_ADMIN_API_URL}/ghost/api/admin/members/`, {
method: 'POST',
headers: { Authorization: `Ghost ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ members: [{ email, subscribed: true }] }),
});
return Response.json({ ok: res.ok });
}What's still on Sanity territory
Ghost won't replace Sanity (or Payload) when you need:
- Product catalogs with multiple variants + image galleries
- Deeply linked reference structures (author has multiple roles, each role belongs to multiple posts)
- Multi-language content with field-level translations
- Custom field schemas your team owns
For those: Sanity or Payload. For blog + newsletter + marketing pages: Ghost saves you days.
Cost picture
| Component | Monthly |
|---|---|
| Ghost (self-host on existing droplet) | $0 marginal |
| Or Ghost Pro (managed) | $9-25/mo |
| Next.js (Vercel Hobby) | $0 |
| Cloudflare DNS + CDN | $0 |
| Total | $0-25/mo |
Compare to "managed CMS + newsletter platform": Webflow CMS + Mailchimp = $50+/mo. Sanity + Buttondown = $20+/mo. Ghost rolls both into one tool you can host yourself.
Pattern reused for the site you're reading + 3 client projects. Get in touch if you want me to wire it for yours.
Topics:
Want to Implement These Strategies?
I can help you apply these insights to your business. Book a free consultation today.
Book Your Free Consultation