Using Ghost as a Headless CMS with Next.js

Using Ghost as a Headless CMS with Next.js

Updated 4 months ago

I recently rebuilt most of my personal site with the main goal of providing a better surface area for writing. There are dozens of viable technology choices available right now to create a self-hosted blog and I spent way too long, and way too much energy trying to find the perfect one.

Spoiler alert: there is no perfect system. Every solution is a hack. Once I accepted this, I could focus on finding the least-hacky setup that would cover 90% of my needs. Ultimately, I ended up building the following system:

  • Ghost.org as a headless CMS - this is where I draft, publish, and edit posts
  • Next.js to build the website with React
  • Vercel to deploy and host the site
Getting data from Ghost

The Ghost API is pretty solid - the documentation is straightforward, and they even have a guide for working with Next.js.

To start, I added a small API file that can fetch data from Ghost:

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

const api = new GhostContentAPI({
  url: 'https://overthought.ghost.io',
  key: 'API_KEY',
  version: "v3"
});

export async function getPosts() {
  return await api.posts
    .browse({
      limit: "all"
    })
    .catch(err => {
      console.error(err);
    });
}

export async function getPostBySlug(slug) {
  return await api.posts
    .read({
      slug
    })
    .catch(err => {
      console.error(err);
    });
}

We can then use these API calls to populate data into a page. Here's a simplified version of my src/pages/overthought/index.tsx file:

import * as React from 'react';
import Page from '../../components/Page';
import OverthoughtGrid from '../../components/OverthoughtGrid'
import { getPosts } from '../../data/ghost'
import { BlogPost } from '../../types'

interface Props {
  posts?: Array<BlogPost>
}

function Overthought({ posts }: Props) {
  return (
    <Page>
      <OverthoughtGrid posts={posts} />
    </Page>
  );
}

Overthought.getInitialProps = async ({ res }) => {
  if (res) {
    const cacheAge = 60 * 60 * 12;
    res.setHeader('Cache-Control', `public,s-maxage=${cacheAge}`);
  }
  const posts = await getPosts();
  return { posts: posts }
}

export default Overthought

In the getInitialProps call, which runs server-side, I decided that caching the entire page for 12 hours at a time was probably safe: I won't be publishing that frequently. This will improve performance if there is any spike in traffic that would otherwise overload the Ghost API.

Client-side caching

It's a bit overkill for now, but one thing that I've been meaning to try is SWR. This package does some cool client-side work to provide data revalidation on refocus, retries on failure, polling, and client-side caching.

One key thing that I wanted to solve for was people navigating between the home page of my site and the /overthought route. These pages both fetch the same posts, so it'd be a waste to require the second fetch to resolve before rendering my list of posts.

Before SWR, I might have reached for a tool like React.useContext to provide some kind of global state wrapper that would keep track of any previously-fetched posts. But Context can get messy, and I hate adding hierarchy to my components.

SWR solves the problem by maintaining a client-side cache of data I've fetched, keyed by the route used for the request. When a user navigates from / to /overthought, SWR will serve stale data from the cache first and then initiate a new request to update that cache with the latest data from the API.

At the end of the day, the same number of network requests are being fired. But the user experience is better: the navigation will feel instant because there's no waiting for a new network request to Ghost to resolve. Here's how our page from above looks with SWR:

import * as React from 'react';
import Page from '../../components/Page';
import OverthoughtGrid from '../../components/OverthoughtGrid'
import { getPosts } from '../../data/ghost'

function Overthought({ posts }) {
  const initialData = props.posts
  const { data: posts } = useSWR('/api/getPosts', getPosts, { initialData })
  
  return (
    <Page>
      <OverthoughtGrid posts={posts} />
    </Page>
  );
}

Overthought.getInitialProps = async ({ res }) => {
  if (res) {
    const cacheAge = 60 * 60 * 12;
    res.setHeader('Cache-Control', `public,s-maxage=${cacheAge}`);
  }
  const posts = await getPosts();
  return { posts: posts }
}

export default Overthought

With the two added lines at the top of the function, we instantly get data served from the client-side cache. The cool thing about this setup is that if the user loads a page that is server-side rendered, SWR will receive initialData that was already fetched on the server, again creating the feeling of an instantaneous page load.

Again: this is overkill.

Rendering post content

My one issue  with Ghost is that they don't return a Markdown version of your posts. Instead, they only return a big string containing all of the HTML for your post. Rendering this HTML string can be a pain: Ghost has a lot of custom elements that they use for rich embeds, like videos, that I don't want to be ingesting.

So instead I was able to hack around this by using react-markdown in conjunction with unified, rehype-parse, rehype-remark, and remark-stringify. I found all of this to be a bit of a headache, and is certainly one of the downsides of using Ghost as a content provider. I've reached out to the team to try and start a discussion about returning a raw Markdown field from the posts API.

Here's how the HTML processing works:

import unified from 'unified'
import parse from 'rehype-parse'
import rehype2remark from 'rehype-remark'
import stringify from 'remark-stringify'
import Markdown from 'react-markdown';

const PostBody({ post }) {
  const md = unified()
    .use(parse)
    .use(rehype2remark)
    .use(stringify)
    .processSync(post.html)
    .toString()

  return <Markdown>{md}</Markdown>
}

Unfortunately I have more work to do to dig into the internals of how the HTML is being parsed - I noticed that it strips out things like alt tags on images, and entire iframes if I use video embeds.

My source files are here if you would like to dig around further - if you happen to know of solutions to these parsing woes, please let me know!

A small favor

Was anything I wrote confusing, outdated, or incorrect? Please let me know! Just write a few words below and I’ll be sure to amend this post with your suggestions.

Follow along

If you want to know about new posts, add your email below. Alternatively, you can subscribe with RSS.