Loading Ghost CMS posts with Astro Content Collections

#tutorial

I recently made the switch to using Ghost as a headless CMS and I needed to load posts into my Astro site. Along with the Ghost posts, I also wanted to continue loading my existing markdown files. One, because I was too lazy to migrate. And two, because I think it’ll be cool to create more interactive mdx posts in the future.

Thankfully, using Astro's Content Layer API and content collections it was straightforward to load content from various sources. And in this blog post, I'll share how I used content collections to load both Ghost posts and markdown files.

What is a Content Collection

Content collections are a way Astro manages sets of content. And the Content Layer API defines an interface to interact with said content. This lets us plug in multiple sources of content, like a usb stick, for us to interact with.

For example, I'm using the in-built glob loader that Astro provides to load up my markdown files. I can also define a zod schema for typechecking. In src/content.config.ts:

import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';

const blog = defineCollection({
  loader: glob({ pattern: "*.md", base: "src/content/blog" }),
  schema: z.object({
		title: z.string(),
		description: z.string(),
		// Transform string to Date object
		pubDate: z
			.string()
			.or(z.date())
			.transform((val) => new Date(val)),
		updatedDate: z
			.string()
			.or(z.date())
			.optional()
			.transform((str) => (str ? new Date(str) : undefined)),
		heroImage: z.string().optional(),
		draft: z.boolean().default(false),
		featured: z.boolean(),
		topics: z
			.array(z.string())
			.optional()
			.default([]),
		bskyPostId: z.string().optional()
	}),
});

export const collections = { blog };

I can then fetch content from this collection via the Content Layer API. In my src/pages/writing/[slug].astro file:

---
import { getCollection, render } from 'astro:content';

export async function getStaticPaths() {
    const posts = await getCollection('blog');
	return posts.map((post) => ({
		params: { slug: post.id },
		props: { post },
	}));
}

const { post } = Astro.props;
const { Content } = await render(post);

---

<h1>{post.data.title}</h1>
<Content />

With these two files I'm able to generate static paths based on the files loaded by the glob loader and render their content. This snippet is copied from an Astro guide with some minor tweaks.

To get this working with Ghost, I need add a few more files.

Loading Ghost Posts

Before I dive into my custom Ghost loader, I want to give a shoutout to MatthiesenXYZ for creating the ghostcms-loader. This loader uses ts-ghost under the hood, and contains far more functionality than I introduced in my custom loader. The downside of this package is that it's pinned to Ghost v5 API, and for my usecase it seemed like it was overkill. For example, I only want to load posts right now and not other parts of Ghost.

Secondly, the Ghost + Astro setup guide is very good and I had most things setup except the custom loader after walking through the guide. I'll be building off the foundation that the guide set.

Creating the Ghost loader

Instead of src/lib/ghost.ts from the Ghost + Astro setup guide. I created two files, one named src/lib/api/ghost.ts and the other src/lib/loaders/ghostPosts.ts

The api/ghost.ts file setup a client using the Ghost content api and the file content is similar to the guide except it specifies the v6 API version and points to my production site. I also followed the guide to setup the CONTENT_API_KEY

import GhostContentAPI from '@tryghost/content-api';

export const ghostClient = new GhostContentAPI({
    url: 'https://<YOUR-GHOST-SITE>.ghost.io',
    key: import.meta.env.CONTENT_API_KEY,
    version: 'v6.0',
});

Next up, I created the custom loader that calls ghostClient. In src/lib/loaders/ghostPosts.ts

import type { Loader, LoaderContext } from 'astro/loaders';
import { ghostClient } from '@lib/api/ghost';


function extractBskyPostId(codeInjection: string): string | undefined {
  const match = codeInjection.match(/<!--\s*METADATA:\s*bskyPostId=([a-zA-Z0-9]+)\s*-->/);
  return match?.[1];
}

export function ghostPostLoader(): Loader {
  return {
    name: 'ghostcms-posts',

    load: async ({ store, logger, parseData }: LoaderContext) => {
      try {
        let page = 1;
        let hasMore = true;
        const allPosts = [];

        logger.info('Starting to fetch Ghost posts...');

        while (hasMore) {
          const posts = await ghostClient.posts.browse({
            limit: 100,
            page: page,
            include: ['tags']
          });

          allPosts.push(...posts);

          const nextPage = posts.meta.pagination.next;
          hasMore = nextPage !== null;
          if (nextPage !== null) {
            page = nextPage;
          }

          logger.info(`Fetched page ${posts.meta.pagination.page} of ${posts.meta.pagination.pages}`);
        }

        logger.info(`Loaded ${allPosts.length} posts from Ghost`);


        for (const post of allPosts) {
          const id = post.slug
          const bskyPostId = extractBskyPostId(post.codeinjection_foot || '');
          // Transformation done here to match the schema of our md blog collection.
          const rawData = {
            title: post.title,
            description: post.custom_excerpt || post.excerpt || '',
            pubDate: post.published_at ?  new Date(post.published_at): undefined,
            updatedDate: post.updated_at ? new Date(post.updated_at) : undefined,
            heroImage: post.feature_image || undefined,
            draft: false, // Ghost posts are published by default
            featured: post.featured || false,
            topics: post.tags?.map(tag => tag.name) || [],
            readingTime: post.reading_time,
            bskyPostId,
          };

          const parsedData = await parseData({
            id,
            data: rawData,
          });

          store.set({
            id,
            data: parsedData,
            rendered: {
              html: post.html || '',
            },
          });
        }

        logger.info('Ghost posts loaded successfully');
      } catch (error) {
        logger.error(`Ghost loader error: ${error instanceof Error ? error.message : 'Unknown error'}`);
        throw error;
      }
    },
  };
}

Now that’s a lot of code, so let's break it down.

Satisfying the Loader Interface

Firstly, every Loader has an interface we must meet. It must return a name of the loader, and a load function. Optionally, it can return a schema as well. But for my case, I'll be defining the schema in content.config.ts

Here’s all we need to satisfy the Loader interface:

export function ghostPostLoader(): Loader {
  return {
    name: 'ghostcms-posts',
    load: async ({ store, logger, parseData }: LoaderContext) => {...},
  }
}    

The load method takes a LoaderContext which contains various props but the ones I use are store, logger, and parseData.

Calling Ghost client to load posts

Within the load method, I call ghostClient to load the posts:

try {
  let page = 1;
  let hasMore = true;
  const allPosts = [];

  logger.info('Starting to fetch Ghost posts...');

  while (hasMore) {
    const posts = await ghostClient.posts.browse({
      limit: 100,
      page: page,
      include: ['tags']
    });

    allPosts.push(...posts);

    const nextPage = posts.meta.pagination.next;
    hasMore = nextPage !== null;
    if (nextPage !== null) {
      page = nextPage;
    }

    logger.info(`Fetched page ${posts.meta.pagination.page} of ${posts.meta.pagination.pages}`);
  } catch (error) {
    logger.error(`Ghost loader error: ${error instanceof Error ? error.message : 'Unknown error'}`);
    throw error;
  }

In Ghost API v6, they removed limit: all meaning we are forced to paginate over posts. Also, ghostClient.posts fetches posts from the Ghost Content API which will only ever return published posts. Making it impossible to preview draft posts locally. When setting Ghost up, I published a couple of posts so I could see them pulled locally. It's annoying but not the end of the world.

I also include: ['tags'] that I convert into topics which are my blogs representation of tags. For example,

topics: post.tags?.map(tag => tag.name) || [],

Transforming the output

This final step is optional. But in my workflow, I wanted to transform the output of the API call to match what my markdown file frontmatter would return. This meant I could re-use the schema declaration in content.config.ts between my markdown files and ghost posts.

for (const post of allPosts) {
  const id = post.slug
  const bskyPostId = extractBskyPostId(post.codeinjection_foot || '');
  // Transformation done here to match the schema of our md blog collection.
  const rawData = {
    title: post.title,
    description: post.custom_excerpt || post.excerpt || '',
    pubDate: post.published_at ?  new Date(post.published_at): undefined,
    updatedDate: post.updated_at ? new Date(post.updated_at) : undefined,
    heroImage: post.feature_image || undefined,
    draft: false, // Ghost posts are published by default
    featured: post.featured || false,
    topics: post.tags?.map(tag => tag.name) || [],
    readingTime: post.reading_time,
    bskyPostId,
  };

  const parsedData = await parseData({
    id,
    data: rawData,
  });

  store.set({
    id,
    data: parsedData,
    rendered: {
      html: post.html || '',
    },
  });
}

One thing to note is, const id = post.slug. Ghost slugs are unique so it's okay setting this as ID. It also matches what glob loader does, where id is the slug-ified markdown filename.

After transforming the data we save it to the data store:

store.set({
  id,
  data: parsedData,
  rendered: {
    html: post.html || '',
  }
});

The store is a key value store scoped to the collection. Meaning the id must be unique otherwise you'll be returning duplicate entries when you fetch the content collection. That's why it's important that the Ghost slugs are unique.

It sets the id of the post (aka the slug) to the parsedData which matches what is defined in the content schema. It also accepts a rendered prop which is set to the post.html. This will make the render() function available on the post in our Astro pages like this: const { Content } = await render(post);

Bluesky post ID

You might have noticed this reference to bskyPostId. This is how I tie a bluesky post to my blog post. And it's how I load the comments/interactions below! To do so I insert a code injection to the post footer that looks like this:

<!-- METADATA: bskyPostId=3m6g6tkputk2j -->

This will then be extracted and parsed by this extractBskyPostId function. Which takes post.codeinjection_foot which is a field found on the post object from Ghost.

function extractBskyPostId(codeInjection: string): string | undefined {
  const match = codeInjection.match(/<!--\s*METADATA:\s*bskyPostId=([a-zA-Z0-9]+)\s*-->/);
  return match?.[1];
}

...

const bskyPostId = extractBskyPostId(post.codeinjection_foot || '');

At the end, I pass this bskyPostId to the rawData and that field can be used throughout my site. One of the downsides of working with Ghost is the inability to define custom metadata. This is a quick workaround to that problem and something that Astro content collections makes simple.

Content config changes

Now that I've setup the Ghost Post loader. I can modify my content.config.ts to export the new collection, reusing the postSchema across both the existing markdown posts and the new Ghost posts.

import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
import { ghostPostLoader } from '@lib/loaders/ghostPosts';

const postSchema =
	z.object({
		title: z.string(),
		description: z.string(),
		// Transform string to Date object
		pubDate: z
			.string()
			.or(z.date())
			.transform((val) => new Date(val)),
		updatedDate: z
			.string()
			.or(z.date())
			.optional()
			.transform((str) => (str ? new Date(str) : undefined)),
		heroImage: z.string().optional(),
		draft: z.boolean().default(false),
		featured: z.boolean(),
		topics: z
			.array(z.string())
			.optional()
			.default([]),
		bskyPostId: z.string().optional(),
		readingTime: z.number().optional()
	})

const blog = defineCollection({
  loader: glob({ pattern: "*.md", base: "src/content/blog" }),
	schema: postSchema
});

const ghostCmsPosts = defineCollection({
  loader: ghostPostLoader(),
	schema: postSchema
});


export const collections = { blog, ghostCmsPosts };

Fetching posts side by side

Finally, how do I actually fetch the posts? I created a utility src/utilities/getAllPosts.ts to load the two content collections side by side.

import { getCollection } from 'astro:content';
import type { CollectionEntry } from 'astro:content';

export type BlogPost = CollectionEntry<'blog'> | CollectionEntry<'ghostCmsPosts'>;

export default async function getAllPosts(): Promise<BlogPost[]> {
  const blogPosts = await getCollection('blog');
  const ghostPosts = await getCollection('ghostCmsPosts');

  const allPosts = [...blogPosts, ...ghostPosts];

  return allPosts.sort((a, b) => {
    const dateA = a.data.pubDate?.getTime() ?? 0;
    const dateB = b.data.pubDate?.getTime() ?? 0;
    return dateB - dateA;
  });
}

And now I can use this utility to fetch all of my posts! Here's the updated src/pages/writing/[slug].astro

---
import { render } from 'astro:content';
import getAllPosts from '@utilities/getAllPosts';
export async function getStaticPaths() {
	const posts = await getAllPosts();

	return posts.map((post) => ({
		params: { slug: post.id },
		props: { post },
	}));
}

const { post } = Astro.props;
const { Content } = await render(post);

---

<h1>{post.data.title}</h1>
<Content />

You made it!

Thanks for making it this far! I hope this tutorial helps you create a custom loader. And more specifically how to set up a Ghost loader. Feel free to check out my PR for the changes in context. And if you have any feedback please let me know!


🦋 likes on Bluesky

Comments

Like this post or add your comment on Bluesky

Want to stay connected?

Subscribe to my newsletter

Weekly, bite-sized, practical tips and insights that have meaningfully improved how I code, design, and write.

No spam. Unsubscribe anytime.