Retour aux guides

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.