← Writing

Headless Ghost and NextJS

Updated 7 days ago

I've taken chrisellis.dev headless. When I started my website, I chose Ghost because of it's amazing publishing experience. It's pretty great.

Unfortunately, I found the theme development experience a little bit clunky. Especially when you compare it to something like Gatsby or NextJS.

So I decided to try out Headless Ghost. Let's see how you can do this.

How to do this

  • Create your NextJS App
  • Prepare your Ghost Server
  • Import use the Content API JavaScript Client to import posts
  • Host it on a Serverless Platform

Create your Next App

The first step is to create your NextJS app. You can find more detailed instructions for doing this at NextJS, but the just of it is to do the following:

# Create your site
yarn create next-app yourdomain.com
cd yourdomain.com

# Start your server
yarn dev

That's it.

Prepare your Ghost Server

This step is a bit more involved. Login to your Ghost site backend.

We need to get a Content API Key to be able to pull content from our Ghost Site.

  1. Click Integrations at the bottom of your Ghost Backend
  2. Then Add custom integration at the bottom of your Integrations page
  3. Enter a decent name
  4. Make note of your Content API Key

The Content API JavaScript Client

Ok, let's start getting fancy and adding a dependency. First we need to import the Content API.

yarn add @tryghost/content-api

This API is used for fetching content. You can use the Admin API to do more complex things like reading, writing, and editing.  

I've chosen to extract this out to a /lib folder for reusability. Create a file /lib/posts.js. We also need to create an .env.local file at the root of your project which NextJS will use for development.

// lib/posts.js
import GhostContentAPI from "@tryghost/content-api";

// Create API instance with site credentials
const api = new GhostContentAPI({
  url: process.env.BLOG_URL,
  key: process.env.API_KEY,
  version: "v3",
});

// .env.local
BLOG_URL="https://yourdomain.com"
API_KEY="f77b5e81e07899d45850bbf248"

Great, now our app will connect to the our Ghost Instance.

To start off with, let's fetch all of the posts so we can list the posts on the index page.

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

  return posts;
}

Now in pages/index.js, we need to import our getPosts() and call it in a getStaticProps. In NextJS, getStaticProps is used to prerender the page at buildtime. Blog posts are a perfect use case for this as they don't change names and slugs very often.

// pages/index.js
// top o' the file
...
import { getPosts } from "../lib/posts";

export const getStaticProps = async () => {
  const posts = await getPosts();

  if (!posts) {
    return {
      notFound: true,
    };
  }
  return {
    props: {
      posts,
    },
  };
};

export default function Home({ posts }) {
  console.log(posts);
...

If you've set everything up correctly, you should now have a list of all of your published posts in your console from your Ghost site. Switch over to your terminal and you will notice that the posts are logged there as well. This demonstrates that getStaticProps is run during the pre-render at build time.

Let's display these posts in a list so we can navigate to them. We've already destructured the posts in our Home component so all we have to do is use plain old React patterns to display. Remove everything but the heading inside of <main>. I'll throw mine in an unordered list.

// pages/index.js
import Link from "next/link";
...
<main className={styles.main}>
  <h1 className={styles.title}>Posts</h1>
  <ul>
  {posts.map((post) => {
      return (
        <li key={post.slug}>
          <Link 
            href="/posts/[slug]" 
            as={`/posts/${post.slug}`}>
            <a>{post.title}</a>
          </Link>
        </li>
      );
    })}
  </ul>
</main>
...

Note we've also imported the Link component to take advantage of NextJS's routing. Onto the post pages.

You may have noticed the href and as in the Link component. As is a decorator on the Link and href is the actual link. Documentation.

We need to create another async function to get a single post by its slug from our Ghost site. Head back over to lib/posts and add the following code.

// lib/posts.js
export async function getSinglePost(postSlug) {
  return await api.posts
    .read({
      slug: postSlug,
    })
    .catch((err) => {
      console.error(err);
    });
}

Remember, Next uses file based routing so let's create a posts folder with a file called [slug].js in it. This is called a dynamic route in NextJS.

Next.js provides dynamic routes for pages that don’t have a fixed URL / slug. The name of the js file will be the variable, in this case the post slug, wrapped in square brackets – [slug].js.

Rendering a Single Post

// pages/posts/[slug].js

import { getSinglePost, getPosts } from "../../lib/posts";

export async function getStaticPaths() {
  const posts = await getPosts();

  const paths = posts.map((post) => ({
    params: { slug: post.slug },
  }));

  return { paths, fallback: false };
}

export async function getStaticProps(context) {
  const post = await getSinglePost(context.params.slug);

  if (!post) {
    return {
      notFound: true,
    };
  }

  return {
    props: { post },
  };
}

const PostPage = ({ post }) => {
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
    </article>
  );
};

export default PostPage;

Walking you through it, first we import our data fetching functions.

Next we use getStaticPaths() to get the list of posts that match the given slug. Hint, should only be one.

Having matched the paths, we pass the matching paths into getStaticProps() via context so Next knows to render this post at build time.

Then we just render it like we would in any other React application. Simple, right? 😕

Final Results

I've added some styling in the css. You can see the repo at https://github.com/csellis/headless-ghost-next.

It's all downhill from here.

Host it on a Serverless Platform

The easiest way to manage this is using Vercel's own hosting platform. Vercel owns NextJS so you get to take advantage of the tight integrations.

I like to use the Vercel CLI as I tend to throw up a lot of small projects. I'll let you follow their install instructions and we'll just go through uploading it to their domain.

Uploading to Vercel
Uploading to Vercel

Once you have the CLI setup and logged in, literally just type vercel and enter a bunch of times.

And you'll be greeted with an amazing deploy error. I'm so excited.

Following the link you'll discover we're missing the environment variables we have in our .env.local file. Since we're going all CLI here, let's take advantage of the vercel env part of it.

➜  yourdomain.com git:(main) vercel env add
Vercel CLI 21.3.3
? Which type of Environment Variable do you want to add? Plaintext
? What’s the name of the variable? API_KEY
? What’s the value of API_KEY? f77b5e81e07899d45850bbf248
? Add API_KEY to which Environments (select multiple)? Production, Preview, Development
✅  Added Environment Variable API_KEY to Project yourdomain-com [592ms]
Adding API_KEY

Do the same for both BLOG_URL and API_KEY.

This time to redeploy we need to run vercel --prod. Et voilà !

Check it out https://yourdomain-com.vercel.app/

We've successfully uploaded our Headless Ghost site with NextJS to Vercel. Now you can test the site out before making a full switch to Headless Ghost.

Next steps

From here you have several options. What I chose to do was host this site on a subdomain while I was working on it over several weekends. You could still visit chrisellis.dev and get to Genuine GhostJS experience.

When I was happy with the Headless site, I swapped the subdomains around. Reach out to me if you'd like to read about that experience.

Finding this useful?

You can get these articles in your inbox, plus updates about projects I'm working on. I won't send you any spam and you can unsubscribe at any time.

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.