/ astro-integrations / How to Use Ghost with Astro: Complete Guide
astro-integrations 10 min read

How to Use Ghost with Astro: Complete Guide

Step-by-step guide to integrating Ghost with your Astro website.

How to Use Ghost with Astro: Complete Guide

Ghost is an open-source publishing platform that started as a blogging tool and has grown into a capable headless CMS. What makes Ghost interesting for Astro developers is that it comes with a well-designed Content API out of the box, memberships, newsletters, and a polished editor that writers actually enjoy using. You can self-host it for free or use Ghost(Pro), whose pricing is published on the Ghost pricing page.

The approach is straightforward: use Ghost as your content backend, pull posts through its Content API, and let Astro generate static pages. You get Ghost's excellent writing experience paired with Astro's performance. This guide targets Astro 6.x and Ghost 6.x, both current as of the checked-on date in the Sources section.

A version note up front, because it bites people. Ghost 6.0 changed how large result sets work. Requesting ?limit=all no longer returns every record. The Content API now caps a single response at 100 items, so any code that assumed limit: "all" returned the full set needs pagination. The examples below reflect that change.

Prerequisites

  • Node.js v22 if you self-host Ghost. Ghost 6.0 only supports Node.js v22 and dropped v18 and v20.
  • Node.js v22.12.0 or higher for the Astro side. Astro 6 dropped Node 18 and 20 entirely, so both sides now need Node 22.
  • An Astro project (npm create astro@latest)
  • A Ghost instance running Ghost 6.x (self-hosted or Ghost(Pro))
  • A Content API key from your Ghost admin panel

Installation

Install the official Ghost Content API client (current published version 1.12.8):

npm install @tryghost/content-api

If you are using TypeScript, install the community types as well. The official Astro Ghost guide recommends this exact package name (current published version 1.3.17):

npm install --save-dev @types/tryghost__content-api

Optionally add an src/env.d.ts file so your editor knows about the typed environment variables, which the official Astro guide also suggests:

// src/env.d.ts
interface ImportMetaEnv {
  readonly GHOST_URL: string;
  readonly GHOST_CONTENT_API_KEY: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

Configuration

Create a Ghost client helper:

// src/lib/ghost.ts
import GhostContentAPI from "@tryghost/content-api";

const ghost = new GhostContentAPI({
  url: import.meta.env.GHOST_URL,
  key: import.meta.env.GHOST_CONTENT_API_KEY,
  version: "v6.0",
});

export default ghost;

The version string "v6.0" matches the current example in the official Ghost JavaScript client docs. The string is the API contract version, not your Ghost release number, so a Ghost 6.x instance speaks v6.0. If your instance is still on Ghost 5.x, pass "v5.0" instead.

Add your Ghost credentials to .env:

GHOST_URL=https://your-ghost-blog.com
GHOST_CONTENT_API_KEY=your_content_api_key_here

Find the Content API key in your Ghost admin panel under Settings > Integrations. Create a custom integration and copy the Content API Key. The URL is your Ghost site's root URL.

Rendering Mode in Astro 6

In Astro 6 the entire site is prerendered to static HTML by default. The docs put it plainly: "By default, your entire Astro site will be prerendered, and static HTML pages will be sent to the browser." That is exactly what you want for a Ghost-backed blog, so you do not need to set anything in astro.config.mjs and you do not need an adapter for the static build.

A piece of history worth knowing if you are following an older tutorial. The output: 'hybrid' mode was removed in Astro 5, and the valid values are now 'static' (the default) and 'server'. You only reach for output: 'server' plus an adapter when you want pages rendered on demand, for example a preview route that reads draft posts from Ghost at request time. To opt a single route into on-demand rendering you add export const prerender = false to that page and install a server adapter; everything else stays static.

For a normal published blog, keep the default static output and let getStaticPaths build every post at compile time, as shown below.

Basic Usage

Fetching All Posts With Pagination

Because Ghost 6.0 caps a single Content API response at 100 items, you can no longer rely on limit: "all" to return your entire archive. Requesting ?limit=all does not error, it just returns at most 100 records. To pull everything, loop through pages using the meta.pagination object that Ghost attaches to every browse response. The client returns an array with a meta property, and meta.pagination.next is null once you reach the last page.

// src/lib/posts.ts
import ghost from "./ghost";

export async function getAllPosts(options = {}) {
  const all = [];
  let page = 1;

  while (true) {
    const posts = await ghost.posts.browse({ limit: 100, page, ...options });
    all.push(...posts);
    // posts.meta.pagination.next is null on the final page
    if (!posts.meta.pagination.next) break;
    page = posts.meta.pagination.next;
  }

  return all;
}

Fetch posts through that helper and render a blog index:

---
// src/pages/blog/index.astro
import { getAllPosts } from "../../lib/posts";
import BaseLayout from "../../layouts/BaseLayout.astro";

const posts = await getAllPosts({
  include: ["tags", "authors"],
  fields: ["id", "title", "slug", "excerpt", "feature_image", "published_at"],
});
---

<BaseLayout title="Blog">
  <h1>Blog</h1>
  {posts.map((post) => (
    <article>
      <a href={`/blog/${post.slug}`}>
        {post.feature_image && (
          <img src={post.feature_image} alt={post.title} loading="lazy" />
        )}
        <h2>{post.title}</h2>
        <p>{post.excerpt}</p>
        <time>{new Date(post.published_at).toLocaleDateString()}</time>
      </a>
    </article>
  ))}
</BaseLayout>

Dynamic post pages with getStaticPaths:

---
// src/pages/blog/[slug].astro
import ghost from "../../lib/ghost";
import { getAllPosts } from "../../lib/posts";
import BaseLayout from "../../layouts/BaseLayout.astro";

export async function getStaticPaths() {
  const posts = await getAllPosts({ fields: ["slug"] });
  return posts.map((post) => ({
    params: { slug: post.slug },
  }));
}

const { slug } = Astro.params;
const post = await ghost.posts.read({ slug }, { include: ["tags", "authors"] });
---

<BaseLayout title={post.title}>
  <article>
    <h1>{post.title}</h1>
    <div class="meta">
      <span>By {post.authors?.[0]?.name}</span>
      <time>{new Date(post.published_at).toLocaleDateString()}</time>
    </div>
    {post.feature_image && (
      <img src={post.feature_image} alt={post.title} />
    )}
    <div class="content" set:html={post.html} />
    <div class="tags">
      {post.tags?.map((tag) => (
        <a href={`/tag/${tag.slug}`}>{tag.name}</a>
      ))}
    </div>
  </article>
</BaseLayout>

You can also build tag and author pages by querying those endpoints:

// Fetch tags (page through them if you have more than 100)
const tags = await ghost.tags.browse({ limit: 100, include: ["count.posts"] });

// Fetch posts by tag
const taggedPosts = await ghost.posts.browse({
  filter: `tag:${tagSlug}`,
  include: ["tags"],
  limit: 100,
});

The same 100-item cap applies to tags.browse and authors.browse. If a single tag has more than 100 posts, reuse the pagination loop from getAllPosts.

Production Tips

  1. Use Ghost's built-in image processing. Ghost stores images with size variants. Append /size/w600/ to image URLs for responsive images without an external image service. This works on both self-hosted and Ghost(Pro) instances.

  2. Page through large archives, then cache. Since Ghost 6.0 caps responses at 100 items, hundreds of posts means several round trips per build. Use the getAllPosts pagination loop above, and build in small delays between pages on very large sites so you do not trip Ghost's rate limits, which the docs explicitly call out for paginated fetches. You can also use Ghost's filter parameter with updated_at to fetch only recently changed content and cache the rest locally.

  3. Set up a webhook for automatic rebuilds. In Ghost admin under Integrations, add a custom webhook that triggers your hosting platform's build hook whenever a post is published or updated.

  • Handle Ghost's HTML output carefully. Ghost returns pre-rendered HTML for post content. The official Astro guide injects it with <Fragment set:html={post.html} />, which avoids an extra wrapper element. Style it with a dedicated .ghost-content CSS class rather than fighting with inline styles. Ghost's default output is clean, but you may want to add Tailwind typography plugin styles. Only call set:html on content you trust, since it bypasses Astro's HTML escaping.

  • Use the fields parameter. When fetching post lists, only request the fields you need (title, slug, excerpt, feature_image). This reduces response size and speeds up builds, especially with large content libraries.

  • Alternatives to Consider

    • Keystatic if you want a free, Git-based CMS that stores content directly in your repository with no external service.
    • Strapi if you need a self-hosted CMS with more flexible content modeling and custom field types.
    • Contentful if you want an enterprise-grade headless CMS with a larger ecosystem and more advanced content modeling.

    Common Errors and Fixes

    Only 100 posts show up even though you asked for all of them. This is the headline Ghost 6.0 change. The docs state that "requesting ?limit=all from any API endpoint will not error, but instead will return a maximum of 100 items," and asking for more than 100 also falls back to 100. The fix is the getAllPosts pagination loop above. Walk the meta.pagination.next value until it is null.

    Cannot read properties of undefined (reading 'pagination'). The meta object only rides along with browse responses. A read call returns a single post object with no meta, so do not try to paginate a read. Use posts.read for one slug and posts.browse for lists.

    TypeScript cannot find a declaration file for @tryghost/content-api. Install the community types with npm install --save-dev @types/tryghost__content-api, which is the package name the official Astro guide points to. Note the double underscore that encodes the scoped package name.

    key errors or 401 / 403 from the API. Confirm you copied the Content API Key, not the Admin API Key, from Settings then Integrations. The Content API key is a single hex string. The Admin key contains a colon and is for a different client (@tryghost/admin-api).

    Version mismatch behavior. Pass version: "v6.0" for a Ghost 6.x instance to match the current official example. If your server still runs Ghost 5.x, use "v5.0". The string is the API contract, not your installed Ghost build number.

    Self-host install fails on Node 18 or 20. Ghost 6.0 only supports Node.js v22 and dropped v18 and v20. Upgrade the Node runtime on the Ghost server. This is separate from the Node version your Astro build runs on.

    output: 'hybrid' is not a valid config value. The hybrid output mode was removed in Astro 5. Valid values are 'static' (the default) and 'server'. For a static Ghost blog, leave output unset. For on-demand routes, set output: 'server', add an adapter, and mark static pages with export const prerender = true, or keep the default and opt individual routes out with export const prerender = false.

    Official Docs and Examples

    Wrapping Up

    Ghost and Astro complement each other well. Ghost gives you a polished writing experience with built-in memberships and newsletters, while Astro delivers the performance of a static site. The Content API is well documented and the official JavaScript client handles filtering cleanly, as long as you remember the Ghost 6.0 hundred-item cap and page through your archive. For blogs and publications where the writing experience matters as much as the developer experience, this combination is hard to beat.

    Sources

    Checked on 2026-05-29.