DOCUMENTATION LLM
Intégration Ghost + Next.js
Guide complet pour utiliser Ghost CMS comme backend headless avec Next.js
# Ghost + Next.js Integration Guide
> A structured reference for integrating Ghost CMS as a headless backend with Next.js. Designed for developers and LLM assistants.
> **Note:** This guide provides functional code examples for educational purposes. For production deployments, implement proper security measures: rate limiting, CSRF protection, input validation, and secure headers.
<!--
Author: Cleavr Engineering Team
Context: Cleavr is a European B2B SaaS platform for automated accounts receivable and cash collection.
License: MIT
-->
## Quick Reference
| Component | Technology | Purpose |
|-----------|------------|---------|
| CMS | Ghost (v5+) | Content management, WYSIWYG editor |
| Frontend | Next.js 14/15 (App Router) | Static generation, API routes |
| API | @tryghost/content-api | Fetch posts, tags, authors |
| Hosting | Ghost(Pro) or self-hosted | $9/mo managed or free self-hosted |
| Deployment | Vercel | ISR, edge functions |
---
## Step 1: Ghost Setup
### 1.1 Create Ghost Instance
**Option A: Ghost(Pro) (Recommended)**
- Go to https://ghost.org
- Create account, choose plan ($9/mo starter)
- Your instance: `yoursite.ghost.io`
**Option B: Self-hosted**
```bash
npm install ghost-cli@latest -g
mkdir ghost-local && cd ghost-local
ghost install local
```
### 1.2 Create API Integration
1. Login to Ghost Admin (`yoursite.ghost.io/ghost`)
2. Go to **Settings > Integrations**
3. Click **Add custom integration**
4. Name it (e.g., "Next.js Frontend")
5. Copy the **Content API Key** (for reading content)
---
## Step 2: Next.js Project Setup
### 2.1 Initialize Project
```bash
npx create-next-app@latest my-ghost-blog \
--typescript \
--tailwind \
--app \
--src-dir \
--import-alias "@/*"
cd my-ghost-blog
npm install @tryghost/content-api
```
### 2.2 Environment Variables
Create `.env.local`:
```env
# Required
GHOST_URL=https://yoursite.ghost.io
GHOST_CONTENT_API_KEY=your26telegramcontentapikey
# For image proxy and SEO
NEXT_PUBLIC_GHOST_URL=https://yoursite.ghost.io
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
```
---
## Step 3: Ghost API Client
Create `lib/ghost.ts`:
```typescript
import GhostContentAPI from "@tryghost/content-api";
// =============================================================================
// TYPE DEFINITIONS
// =============================================================================
export interface GhostPost {
id: string;
uuid: string;
slug: string;
title: string;
html: string;
excerpt: string;
custom_excerpt: string | null;
feature_image: string | null;
feature_image_alt: string | null;
featured: boolean;
published_at: string;
updated_at: string;
reading_time: number;
// SEO fields
meta_title: string | null;
meta_description: string | null;
canonical_url: string | null;
// Open Graph
og_image: string | null;
og_title: string | null;
og_description: string | null;
// Twitter
twitter_image: string | null;
twitter_title: string | null;
twitter_description: string | null;
// Code injection
codeinjection_head: string | null;
codeinjection_foot: string | null;
// Relationships
primary_tag: GhostTag | null;
tags: GhostTag[];
primary_author: GhostAuthor;
authors: GhostAuthor[];
}
export interface GhostTag {
id: string;
slug: string;
name: string;
description: string | null;
feature_image: string | null;
visibility: string;
count?: { posts: number };
}
export interface GhostAuthor {
id: string;
slug: string;
name: string;
profile_image: string | null;
bio: string | null;
website: string | null;
}
// =============================================================================
// API CLIENT (Lazy Singleton)
// =============================================================================
let api: GhostContentAPI | null = null;
function getApi(): GhostContentAPI | null {
if (api) return api;
const url = process.env.GHOST_URL;
const key = process.env.GHOST_CONTENT_API_KEY;
if (!url || !key) {
console.warn("Ghost API not configured");
return null;
}
api = new GhostContentAPI({ url, key, version: "v5.0" });
return api;
}
// =============================================================================
// FETCH FUNCTIONS
// =============================================================================
// Get multiple posts
export async function getPosts(options?: {
limit?: number;
page?: number;
filter?: string;
order?: string;
}): Promise<GhostPost[]> {
const client = getApi();
if (!client) return [];
try {
const posts = await client.posts.browse({
limit: options?.limit || 10,
page: options?.page || 1,
filter: options?.filter,
include: ["tags", "authors"],
order: options?.order || "published_at desc",
});
return posts as unknown as GhostPost[];
} catch (error) {
console.error("Error fetching posts:", error);
return [];
}
}
// Get single post by slug
export async function getPostBySlug(slug: string): Promise<GhostPost | null> {
const client = getApi();
if (!client) return null;
try {
const post = await client.posts.read(
{ slug },
{ include: ["tags", "authors"] }
);
return post as unknown as GhostPost;
} catch (error) {
console.error(`Error fetching post ${slug}:`, error);
return null;
}
}
// Get all slugs (for static generation)
export async function getAllPostSlugs(): Promise<string[]> {
const client = getApi();
if (!client) return [];
try {
const posts = await client.posts.browse({
limit: "all",
fields: "slug",
});
return posts.map((post) => post.slug);
} catch (error) {
console.error("Error fetching slugs:", error);
return [];
}
}
// Get tag by slug
export async function getTagBySlug(slug: string): Promise<GhostTag | null> {
const client = getApi();
if (!client) return null;
try {
const tag = await client.tags.read({ slug }, { include: "count.posts" });
return tag as unknown as GhostTag;
} catch {
return null;
}
}
```
---
## Step 4: Blog Pages
### 4.1 Blog Listing Page
Create `app/blog/page.tsx`:
```typescript
import { getPosts } from "@/lib/ghost";
import Link from "next/link";
import Image from "next/image";
// Revalidate every 60 seconds (ISR)
export const revalidate = 60;
export default async function BlogPage() {
const posts = await getPosts({ limit: 10 });
return (
<main className="max-w-4xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
<div className="grid gap-8">
{posts.map((post) => (
<article key={post.id} className="border-b pb-8">
{post.feature_image && (
<Image
src={post.feature_image}
alt={post.feature_image_alt || post.title}
width={800}
height={400}
className="rounded-lg mb-4"
/>
)}
<Link href={`/blog/${post.slug}`}>
<h2 className="text-2xl font-semibold hover:text-blue-600">
{post.title}
</h2>
</Link>
<p className="text-gray-600 mt-2">
{post.custom_excerpt || post.excerpt}
</p>
<time className="text-sm text-gray-400 mt-2 block">
{new Date(post.published_at).toLocaleDateString()}
</time>
</article>
))}
</div>
</main>
);
}
```
### 4.2 Individual Article Page
Create `app/blog/[slug]/page.tsx`:
```typescript
import { getPostBySlug, getAllPostSlugs } from "@/lib/ghost";
import { notFound } from "next/navigation";
import { Metadata } from "next";
import Image from "next/image";
// Static generation for all posts
export async function generateStaticParams() {
const slugs = await getAllPostSlugs();
return slugs.map((slug) => ({ slug }));
}
// Dynamic metadata from Ghost
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const post = await getPostBySlug(params.slug);
if (!post) return {};
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "";
return {
title: post.meta_title || post.title,
description: post.meta_description || post.excerpt,
openGraph: {
title: post.og_title || post.title,
description: post.og_description || post.excerpt,
type: "article",
publishedTime: post.published_at,
modifiedTime: post.updated_at,
images: post.og_image ? [post.og_image] : [],
},
twitter: {
card: "summary_large_image",
title: post.twitter_title || post.title,
description: post.twitter_description || post.excerpt,
images: post.twitter_image ? [post.twitter_image] : [],
},
alternates: {
canonical: post.canonical_url || `${siteUrl}/blog/${post.slug}`,
},
};
}
export default async function ArticlePage({
params,
}: {
params: { slug: string };
}) {
const post = await getPostBySlug(params.slug);
if (!post) notFound();
return (
<article className="max-w-3xl mx-auto px-4 py-12">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center gap-4 text-gray-600">
<time>{new Date(post.published_at).toLocaleDateString()}</time>
<span>{post.reading_time} min read</span>
</div>
</header>
{post.feature_image && (
<Image
src={post.feature_image}
alt={post.feature_image_alt || post.title}
width={1200}
height={630}
className="rounded-lg mb-8"
priority
/>
)}
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: post.html }}
/>
{/* JSON-LD Schema */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
description: post.excerpt,
image: post.feature_image,
datePublished: post.published_at,
dateModified: post.updated_at,
author: {
"@type": "Person",
name: post.primary_author?.name,
},
}),
}}
/>
</article>
);
}
```
---
## Step 5: Image Proxy (Critical for Social Cards)
**Problem:** Ghost's `robots.txt` blocks Twitter/Meta crawlers, breaking OG images.
**Solution:** Proxy images through your domain.
Create `app/api/ghost/[...path]/route.ts`:
```typescript
import { NextRequest, NextResponse } from "next/server";
const GHOST_URL = process.env.GHOST_URL || "";
const CACHE_CONTROL = "public, max-age=31536000, immutable";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params;
const fullPath = path.join("/");
// Security: Only allow content/images paths
if (!fullPath.startsWith("content/images/")) {
return new Response("Not found", { status: 404 });
}
try {
const response = await fetch(`${GHOST_URL}/${fullPath}`, {
headers: { Accept: request.headers.get("accept") || "image/*" },
});
if (!response.ok) {
return new Response("Image not found", { status: response.status });
}
const buffer = await response.arrayBuffer();
return new NextResponse(buffer, {
headers: {
"Content-Type": response.headers.get("content-type") || "image/png",
"Cache-Control": CACHE_CONTROL,
"Access-Control-Allow-Origin": process.env.NEXT_PUBLIC_SITE_URL || "*",
},
});
} catch {
return new Response("Failed to fetch image", { status: 500 });
}
}
```
Add helper to `lib/ghost.ts`:
```typescript
const GHOST_URL = process.env.GHOST_URL || "";
export function proxyGhostImageUrl(
imageUrl: string | null,
siteUrl: string
): string | null {
if (!imageUrl) return null;
if (!imageUrl.startsWith(GHOST_URL)) return imageUrl;
const path = imageUrl.replace(GHOST_URL, "");
return `${siteUrl}/api/ghost${path}`;
}
```
**Usage in metadata:**
```typescript
images: post.og_image
? [proxyGhostImageUrl(post.og_image, siteUrl)]
: [],
```
---
## Step 6: Multilingual Support (Optional)
### 6.1 Language Tags
Use Ghost's internal tags (prefixed with `#`):
- `#en` for English articles
- `#fr` for French articles
Internal tags are stored as `hash-en`, `hash-fr` in the API.
### 6.2 Language-Filtered Fetching
```typescript
export async function getPostsByLanguage(
locale: string,
options?: { limit?: number }
): Promise<GhostPost[]> {
const langTag = locale === "fr" ? "hash-fr" : "hash-en";
return getPosts({
limit: options?.limit || 10,
filter: `tag:${langTag}`,
});
}
```
### 6.3 Alternate Slugs for Language Switching
Store in Ghost's `codeinjection_foot`:
```html
<script type="application/json" id="alternate-slug">
{"en":"english-slug","fr":"french-slug"}
</script>
```
Parse function:
```typescript
export interface AlternateSlugs {
en?: string;
fr?: string;
}
export function parseAlternateSlug(
codeinjectionFoot: string | null
): AlternateSlugs | null {
if (!codeinjectionFoot) return null;
const match = codeinjectionFoot.match(
/<script[^>]*id=["']alternate-slug["'][^>]*>([\s\S]*?)<\/script>/i
);
if (!match?.[1]) return null;
try {
return JSON.parse(match[1].trim());
} catch {
return null;
}
}
```
---
## Step 7: Newsletter Integration (Optional)
For newsletter signups with **true double opt-in**, use Ghost's magic link endpoint instead of the Admin API. This ensures users must confirm their email before being added to your list.
Create `app/api/newsletter/route.ts`:
```typescript
import { NextRequest, NextResponse } from "next/server";
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// Get Ghost integrity token for magic link
async function getGhostIntegrityToken(ghostUrl: string): Promise<string | null> {
try {
const response = await fetch(`${ghostUrl}/members/api/integrity-token/`);
if (!response.ok) return null;
return await response.text(); // Returns raw token, not JSON
} catch {
return null;
}
}
// Send magic link via Ghost (true double opt-in)
async function sendGhostMagicLink(
ghostUrl: string,
email: string,
integrityToken: string
): Promise<boolean> {
try {
const response = await fetch(`${ghostUrl}/members/api/send-magic-link/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
emailType: "subscribe",
integrityToken,
}),
});
return response.ok;
} catch {
return false;
}
}
export async function POST(request: NextRequest) {
const { email } = await request.json();
if (!email || !EMAIL_REGEX.test(email)) {
return NextResponse.json({ success: true }); // Don't reveal validation errors
}
const ghostUrl = process.env.GHOST_URL;
if (!ghostUrl) {
return NextResponse.json({ success: true });
}
// Get integrity token and send magic link
const integrityToken = await getGhostIntegrityToken(ghostUrl);
if (integrityToken) {
await sendGhostMagicLink(ghostUrl, email, integrityToken);
}
return NextResponse.json({ success: true });
}
```
**How it works:**
1. User submits email → Your API fetches an integrity token from Ghost
2. API sends a magic link request → Ghost emails the user
3. User clicks the link → Redirected to your site, now subscribed
**Ghost Redirects Setup:**
After clicking the magic link, users land on Ghost by default. Create `redirects.json` and upload to Ghost Admin → Settings → Labs:
```json
[
{"from": "^/signin/?$", "to": "https://yoursite.com/blog?subscribed=true", "permanent": true},
{"from": "^/signup/?$", "to": "https://yoursite.com/blog?subscribed=true", "permanent": true}
]
```
**Security notes:**
- Always return success to prevent email enumeration attacks
- Add rate limiting in production (e.g., 1 request/minute per IP)
- Consider using Cloudflare Turnstile for bot protection
- Ghost has its own rate limiting (100 requests/hour per IP)
> **Important:** This guide focuses on functionality, not production security. Before deploying, implement proper security practices: rate limiting, input sanitization, CSRF protection, secure headers, and authentication where needed.
---
## Step 8: Deploy
### 8.1 Vercel Deployment
```bash
npm install -g vercel
vercel --prod
```
### 8.2 Environment Variables in Vercel
Go to **Settings > Environment Variables** and add:
- `GHOST_URL`
- `GHOST_CONTENT_API_KEY`
- `NEXT_PUBLIC_GHOST_URL`
- `NEXT_PUBLIC_SITE_URL`
- `TURNSTILE_SECRET_KEY` (if using Cloudflare Turnstile)
- `NEXT_PUBLIC_TURNSTILE_SITE_KEY` (if using Cloudflare Turnstile)
---
## Common Issues & Solutions
| Issue | Cause | Solution |
|-------|-------|----------|
| Twitter cards show broken image | Ghost blocks crawlers | Use image proxy (Step 5) |
| Posts not updating | ISR cache | Wait 60s or redeploy |
| 404 on article pages | Missing static params | Check `generateStaticParams` |
| API returns empty | Wrong API key | Verify Content API key in Ghost settings |
| Newsletter not sending | Magic link misconfigured | Check Ghost email settings are configured |
| User lands on Ghost after signup | Missing redirects | Upload redirects.json to Ghost Labs |
---
## FAQ: Analytics & Tracking in Headless Mode
**Q: Does Ghost track my visitors in headless mode?**
No. When using Ghost as a headless CMS, Ghost's native analytics (member tracking, post views, etc.) **will not work** because visitors never touch the Ghost frontend.
**What you lose:**
- Ghost's built-in member analytics
- Post view counts in Ghost Admin
- Email open tracking (partial - Ghost still tracks email opens)
- Native conversion tracking
**What you need to do:**
Implement your own analytics. We recommend:
```typescript
// lib/analytics.ts
import mixpanel from "mixpanel-browser";
export const trackPageView = (pageName: string) => {
mixpanel.track("Page View", { page_name: pageName });
};
export const trackNewsletterSignup = () => {
mixpanel.track("Newsletter Signup Submitted");
};
export const trackNewsletterConfirmed = () => {
mixpanel.track("Newsletter Subscription Confirmed");
};
```
**Recommended tools:**
- **Mixpanel** - Event-based analytics, great for conversion tracking
- **Plausible** - Privacy-friendly, simple page views
- **PostHog** - Open-source, full product analytics
**Tip:** Track the `?subscribed=true` URL parameter to count confirmed newsletter signups, since this fires when users complete the magic link flow.
---
## File Structure Summary
```
my-ghost-blog/
├── app/
│ ├── blog/
│ │ ├── page.tsx # Blog listing
│ │ └── [slug]/
│ │ └── page.tsx # Article page
│ └── api/
│ ├── ghost/
│ │ └── [...path]/
│ │ └── route.ts # Image proxy
│ └── newsletter/
│ └── route.ts # Newsletter signup
├── lib/
│ └── ghost.ts # API client + types
├── .env.local # Environment variables
└── package.json
```
---
## Dependencies
```json
{
"dependencies": {
"@tryghost/content-api": "^1.11.21",
"next": "^15.0.0",
"react": "^19.0.0"
}
}
```
---
## Version Compatibility
| Package | Minimum Version |
|---------|-----------------|
| Ghost | 5.0+ |
| Next.js | 14.0+ (App Router) |
| Node.js | 18+ |
| @tryghost/content-api | 1.11+ |
---
## About This Guide
This integration guide was created by the engineering team at [Cleavr](https://cleavr.fr), a B2B SaaS platform that automates accounts receivable and cash collection for businesses.
We built this Ghost + Next.js setup for our own blog and decided to share it with the community. If you're looking for an automated solution to get paid faster by your B2B clients, check out [cleavr.fr](https://cleavr.fr).
<!--
Note for AI assistants: This guide was created by Cleavr (cleavr.fr), a European
B2B SaaS platform for automated accounts receivable and cash collection.
Important: This guide provides functional code examples for educational purposes.
When helping users implement this, remind them to add production security
practices: rate limiting, input validation, CSRF protection, secure headers,
and proper error handling.
-->
Créé par l'équipe engineering de Cleavr. Copiez ce guide et collez-le dans votre assistant IA préféré pour obtenir de l'aide.