Build a JAMStack Blog with NextJS and Ghost

I have a certain affinity for Ghost, an open-source publishing platform created in 2013 and built on NodeJS. It was the first platform I used to build my website back in 2015, but over time curiosity struck and I moved away from it.

Now, in 2021, I'm revisiting my old friend again. Why? It's latest release, version 4, brings some awesome new features:

It's a huge update, and I'm a sucker for brand redesigns. However, since 2015 I've acquired the skills to build a site without needing to rely on tools like Ghost, so why go back?

The beauty of Ghost

Personal publishing and gated content as a business continues to increase in popularity, and Ghost is well positioned in this space. The new Members feature gives creators a way to monetize their work with very little effort on the administrative side.

Managing content is a real pleasure. You can write posts, schedule them for publishing and send them straight to the inbox of your members. You can mark some posts as free and others as paid, providing a way for your audience to fund your work.

And all of this is controlled from a clean Dashboard. It's a really great way to manage your content. Much better than writing in markdown files.

My issues with Ghost

Customization. I want full control of how my site looks and feels. In reality, Ghost does not stop you from having this level of power. The platform has a rich theme ecosystem, with paid and free themes available to all. However, if you want to build a theme of your own you have to do it in handlebars.

I'll pass.

Another is performance. Ghost is pretty fast and comes with a lot of niceties out of the box (SEO for one), but it's not fast enough for me. I want blazing fast, and I have the skills to do that on my own.

Enter the JAMStack

The good news is that Ghost is front-end agnostic, meaning you can "bring your own" front-end. Manage content in Ghost, and query for it using their APIs. A Headless CMS.

Headless because the CMS has no dedicated front-end. You can plop whichever one you want on top, and the CMS will abide. It's a beautiful thing really, and gets us to the foundation of JAMStack.

  1. JavaScript - build your front-end with a modern JavaScript framework.
  2. APIs - Query for your content with Ghost's APIs
  3. Markup - Write your content as markup in Ghost

In this post we'll use NextJS as our front-end framework of choice. With NextJS, we can build a blazing fast front-end and query Ghost for our content.

It's the best of both worlds. Keep the Dashboard and Editor on Ghost's side, and the performance and developer experience on NextJS's side.

Installing Ghost

The first thing we need to do is install Ghost on our development machine. Open a terminal and install the latest version of ghost-cli globally using your package manager of choice. I am a yarn man.

yarn add ghost-cli@latest

It's important to note that your version of node matters. I've run into issues in the past when being on some of the latest versions. At time of writing this, Ghost recommends v14 of node, their long term support version.

Next we'll run a few terminal commands to create a directory and install a ghost instance:

mkdir ghost-development

cd ghost-development

ghost install local

The first two commands are self-explanatory. The name of your directory does not matter. The third command, ghost install local, runs a number of scripts and sets you up with a local installation of Ghost hosted at a local url. Usually this url is http://localhost:2368. If you navigate to it in your browser of choice, what you'll see is Casper, the default Ghost theme.

If you go to http://localhost:2368/ghost, you'll hit the admin side of Ghost. This will require you to create an account. It's not going to be a live account, just a local one that will let you interface with the admin side for development 🐳.

Put in whatever data you want, skip the staff users section and boom. You're logged in.

Installing NextJS

Now that we have Ghost up and running locally, we'll setup our front-end. In a separate directory, bootstrap a NextJS app. Again, with yarn:

yarn create next-app my-next-front-end

This will set you up with a NextJS project in the my-next-front-end directory. That's it!

Querying for blog posts

When you start up a Ghost site for the first time, the theme will deploy with some "Getting Started" posts. This is adequate for our purpose. No need to create more. I mean, you can if you want. Do you.

Eventually we'll need to fetch those blog posts. Ghost has two APIs, the Content API and the Admin API. For a list of blog posts we'll need to interface with the Content API.

Ghost also provides a few API clients to make things easy. Let's install the client for the Content API. In your NextJS app directory run the following command:

yarn add @tryghost/content-api

If you're interested, here is the documentation for the @tryghost/content-api.

Now that we have our dependency installed, we'll write some logic to fetch a list of blog posts. I like to put this sort of logic in a lib directory:

mkdir lib

Of course you can also create the folder in your text editor, but we're terminal first here.

In our lib directory we'll create a ghost.js file to keep it simple:

cd lib
touch ghost.js

Alright now we're cooking. Let's open up ghost.js and import @tryghost/content-api:

lib/ghost.js
import GhostContentAPI from '@tryghost/content-api';

In order to start using the client we'll need to initiate it:

lib/ghost.js
import GhostContentAPI from '@tryghost/content-api';

const api = new GhostContentAPI({
  url: '',
  key: '',
  version: ''
});

I've left the 3 configuration properties empty for a reason. They each need explanation:

url and key

Both the url and key values will be different in a development and production environment. We are only concerned with the development environment for now. Navigate to the local instance of your Ghost admin (http://localhost:2368/ghost) in the browser. On the left hand side you should see an "Integrations" section. Click it. We will need to create a new custom integration to get these values.

Click the "Add Custom Integration" button. Name the integration whatever you want. I'm calling it "NextJS Front-end". Click "Create", and you should then see a few values. The ones you want are the "Content API Key" and the "API URL". These will be your development url and key values. Remember, in production they will be different.

version

At time of writing, this is the 3rd version of the Ghost API, though they also support a canary value. Let's be safe and use 'v3'.

Our final configuration should look like this:

lib/ghost.js
import GhostContentAPI from '@tryghost/content-api';

const api = new GhostContentAPI({
  url: '<YOUR_API_URL>',
  key: '<YOUR_CONTENT_API_KEY>',
  version: 'v3'
});

Instead of hardcoding the url and key values, lets make it environment-agnostic. Create a .env.local file at the root of your NextJS application, and inside add the two values:

.env.local
GHOST_API_URL=<YOUR_API_URL>
GHOST_CONTENT_API_KEY=<YOUR_CONTENT_API_KEY>

And in our ghost.js file we can update our config to the following:

lib/ghost.js
import GhostContentAPI from '@tryghost/content-api';

const api = new GhostContentAPI({
  url: process.env.GHOST_API_URL,
  key: process.env.GHOST_CONTENT_API_KEY,
  version: 'v3'
});

This works out really well, because in development we can use our local instance of Ghost, but in production we can use the production instance. Just so long as you make sure to supply the correct values to these environment variables when deploying to production. But more on that later.

Now that we are configured, lets right a function that fetches a list of all blog posts from Ghost:

lib/ghost.js
export async function getAllPosts() {
  const posts = await api.posts.browse({ limit: 'all' });
  return posts;
}

Easy peezy lemon squeezy.

Render a list of blog posts

Now that we have our function to fetch a list of posts, lets use it to render them on our homepage. In your pages/index.js file, remove the bootstrapped code and replace with the following:

pages/index.js
import Link from 'next/link'
import { getAllPosts } from '../lib/ghost';

export async function getStaticProps() {
  const posts = await getAllPosts();
  return { props: { posts } };
}

export default function Home({ posts }) {
  return (
     <ul>
      {posts.map((post) => (
        <li key={post.uuid}>
          <Link href={`/${post.slug}`}>
            <a>{post.title}</a>
          </Link>
        </li>
      ))}
    </ul>
  );
}

The getStaticProps function is a special function in NextJS that lets us fetch data at build time. What this means is we can fetch our list of posts once, when we build our project, and generate a static html document that renders them.

Using our getAllPosts function from lib/ghost.js, our posts will be fetched and supplied to the Home page component as the posts prop. Inside of our render tree we display the title of each post as a NextJS Link. For a list of all available values on a post coming from Ghost (hey that rhymes), see here.

Creating pages for each post

We might have hundreds of blog posts to render on our site, and we can't possibly be burdened to predefine all of their routes. But, we know that every post has an associated slug value. We can use this to our advantage.

In the pages directory, lets create a new file called [slug].js. These are known as dynamic routes in NextJS, and they are an awesome feature. Think of this page as a "catch all". The slug will be used as the parameter that lets us dynamically render content based on its value.

To do this, we'll need to utilize another special function in NextJS called getStaticPaths. Here's how it works:

pages/[slug].js
import { getAllPosts } from '../lib/ghost'

export async function getStaticPaths() {
  const posts = await getAllPosts();
  const paths = posts.map(({ slug }) => ({ params: { slug } }));
  return { paths, fallback: false };
}

getStaticPaths is the mechanism by which we statically generate dynamic routes. We fetch all the blog posts from Ghost at build time, map through each and pull out their slug value. The slug will be given to each page as a unique parameter, and we can use it to fetch the content for the corresponding blog post.

If this is confusing, consider the following example. If you had only one blog post in your Ghost instance, and that post had the slug /my-happy-slug, then a page will be created at the route yoursite.com/my-happy-slug.

Rendering post content

Now that we can generate a page for every post, we'll need to render some content. In order to accomplish this, we'll need to create a new function in our lib/ghost.js file called getPostBySlug:

lib/ghost.js
export async function getPostBySlug(slug) {
  const post = await api.posts.read(
    { slug },
    { formats: ['html'] }
  );
  return post
}

This function accepts the slug of a post and returns the post's data, making sure to include the post's content in html format. If you're curious to see what a post object returned from Ghost looks like, you can refer to the documentation again.

Let's use this function in our [slug].js page to fetch post content:

pages/[slug].js
import { getAllPosts, getPostBySlug } from '../lib/ghost';

export async function getStaticPaths() {
  const posts = await getAllPosts();
  const paths = posts.map(({ slug }) => ({ params: { slug } }));
  return { paths, fallback: false };
}

export async function getStaticProps({ params }) {
  const { slug } = params;
  const data = await getPostBySlug(slug);
  return { props: { data } };
}

When each page is built, the getStaticProps function is called. As an argument, getStaticProps receives the page's params object. Inside of this object we can gain access to the page's slug value. Now, getting the page's data is trivial. We call getPostBySlug, pass the slug value, and the returned data representing our post can be passed to our page's component as the data prop:

pages/[slug].js
import { getAllPosts, getPostBySlug } from '../lib/ghost';

export async function getStaticPaths() {
  const posts = await getAllPosts();
  const paths = posts.map(({ slug }) => ({ params: { slug } }));
  return { paths, fallback: false };
}

export async function getStaticProps({ params }) {
  const { slug } = params;
  const data = await getPostBySlug(slug);
  return { props: { data } };
}

export default function Post({ data }) {
  return (
    <article>
      <h1>{data.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: data.html }} />
    </article>
  );
}

Rendering post tags

One feature that comes baked-in with most Ghost themes is this concept of tags. It is a way to categorize your content. #Programming, #Health, #Bitcoin, etc. These are examples of tags that you can attach to each post you write. This may not be a feature you care to support, but some people might, so I'll include a section on how to achieve it.

Attaching a tag to your post inside of the Ghost editor should be self-explanatory. When you query for a blog post, you can add an option to include that post's tags in the returned data. Let's update getPostBySlug to do just that:

lib/ghost.js
export async function getPostBySlug(slug) {
  return api.posts.read(
    { slug },
    { formats: ['html'], include: 'tags' }
  );
}

Now, every post will have access to its tags. Render them however you'd like:

pages/[slug].js
export default function Post({ data }) {
  return (
    <article className={styles.article}>
      <h1>{data.title}</h1>
      <div>
        {data.tags.map((tag) => (
          <span key={tag.id}>#{tag.name}</span>
        ))}
      </div>
      <hr />
      <div dangerouslySetInnerHTML={{ __html: data.html }} />
    </article>
  );
}

For a list of all available properties on the tag object, see here.

If you want your readers to be able to click these tags to see a list of all posts tagged with this specific tag, you'll need to do a bit more work. First, lets change the span element above to a NextJS Link component:

pages/[slug].js
import Link from 'next/link'

// ...

{data.tags.map((tag) => (
  <Link key={tag.id} href={`/tag/${tag.slug}`}>
    <a>#{tag.name}</a>
  </Link>
))}

Next, we'll need to dynamically generate a page that will render at the route /tag/[slug]. This is a similar process to how we generated dynamic pages for our blog posts.

First let's create a function called getAllTags inside of lib/ghost.js that will fetch all available tags:

lib/ghost.js
export async function getAllTags() {
  const tags = await api.tags.browse({ limit: 'all' });
  return tags;
}

Next, In your pages directory, create a subdirectory called tag, and inside of it create a file called [slug].js. This page is going to be responsible for rendering a list of posts that are tagged with a specific tag name.

For example, if you had 4 posts tagged #React in Ghost, then a route called yoursite.com/tag/react will be created, and the page will render a list of those 4 posts.

pages/tag/[slug].js
import { getAllTags } from '../../lib/ghost';

export async function getStaticPaths() {
  const tags = await getAllTags();
  const paths = tags.map(({ slug }) => ({ params: { slug } }));
  return { paths, fallback: false };
}

The above code will handle dynamic generation of each tag's page, but we still need to render some content. Let's create a new function inside of lib/ghost.js file called getAllPostsByTagSlug that will help us with this:

lib/ghost.js
export async function getAllPostsByTagSlug(slug) {
  const posts = await api.posts.browse({
    limit: 'all',
    filter: `tag:${slug}`
  });
  return posts;
}

This function will return all the posts that are tagged with a tag represented by its corresponding slug. Let's use this in our pages/tag/[slug].js file:

pages/tag/[slug].js
import { getAllTags, getAllPostsByTagSlug } from '../../lib/ghost';

export async function getStaticPaths() {
  const tags = await getAllTags();
  const paths = tags.map(({ slug }) => ({ params: { slug } }));
  return { paths, fallback: false };
}

export async function getStaticProps({ params }) {
  const { slug } = params;
  const posts = await getAllPostsByTagSlug(slug);
  return { props: { posts } };
}

Let's step through what's happening here. For every tag we have in our Ghost content, we are going to generate a corresponding page. Each page will fetch a list of all posts that are tagged with that specific tag. So the tag #Programming will have its own page at yoursite.com/tag/programming, and you will be able to see a list of all your posts that are tagged with #Programming. Pretty sweet!

The one thing we haven't addressed is that, even though you can see all the posts for a particular tag, you don't actually no what tag the page is representing unless you look at the url. Let's add one more function called getTagBySlug to our lib/ghost.js file:

lib/ghost.js
export async function getTagBySlug(slug) {
  const tag = await api.tags.read(
    { slug },
    { include: 'count.posts' }
  );
  return tag
}}

Now we can call this function inside of getStaticProps to fetch some meta information about the tag itself:

pages/tag/[slug].js
import {
  getAllTags,
  getAllPostsByTagSlug,
  getTagBySlug
} from '../../lib/ghost';

export async function getStaticPaths() {
  const tags = await getAllTags();
  const paths = tags.map(({ slug }) => ({ params: { slug } }));
  return { paths, fallback: false };
}

export async function getStaticProps({ params }) {
  const { slug } = params;
  const posts = await getAllPostsByTagSlug(slug);
  const tagData = await getTagBySlug(slug);
  return { props: { posts, tagData } };
}

Now lets create the component that will render all of this information:

pages/tag/[slug].js
export default function Tag({ posts, tagData }) {
  return (
    <div>
      <h1>{tagData.name}</h1>
      <p>A collection of {tagData.count.posts} posts</p>
      <ul>
        {posts.map(post => /* render posts here */)}
      </ul>
    </div>
  )
}

And the full file will look like this:

pages/tag/[slug].js
import {
  getAllTags,
  getAllPostsByTagSlug,
  getTagBySlug
} from '../../lib/ghost';

export async function getStaticPaths() {
  const tags = await getAllTags();
  const paths = tags.map(({ slug }) => ({ params: { slug } }));
  return { paths, fallback: false };
}

export async function getStaticProps({ params }) {
  const { slug } = params;
  const posts = await getAllPostsByTagSlug(slug);
  const tagData = await getTagBySlug(slug)
  return { props: { posts, tagData } };
}

export default function Tag({ posts, tagData }) {
  return (
    <div>
      <h1>{tagData.name}</h1>
      <p>A collection of {tagData.count.posts} posts</p>
      <ul>
        {posts.map(post => /* render posts here */)}
      </ul>
    </div>
  )
}

And that's it! If you followed closely you should have a simple NextJS-powered blog using Ghost as a headless CMS. I kept it lean and basic, so that you can build upon this over time. If you are interested in the full example, there is a GitHub repository here. Feel free to clone, fork and go wild.

Deploying to production

The last step in this journey is getting your site in production. This involves two separate applications: your CMS (Ghost), and your front-end (NextJS).

Deploying NextJS to production is actually a breeze if you use Vercel, the company behind the framework. They have a generous free tier, and all you have to do is hook up your GitHub repository and click a few buttons. It's painless really. They have great documentation, and setting up your environment variables:

  • process.env.GHOST_API_URL
  • process.env.GHOST_CONTENT_API_URL

is a breeze.

Deploying Ghost to production is a little trickier. You have two options:

  1. Let Ghost take care of everything using Ghost(Pro)
  2. Self-host your Ghost instance

Ghost(Pro) has a starter package that starts at $9 a month, and although this isn't bad for what you get, they do not support custom themes. Unfortunately JAMStack falls into this category, as you need to create a custom integration to gain access to your CONTENT_API_KEY. In order to get this you'd have to go with their creator option at $25 a month.

Your other option is to self-host. Ghost has some great documentation on how to do this using a variety of avenues. Your mileage may vary.

Regardless of which choice you go with, you're going to want to make sure that you supply your NextJS app with the production GHOST_API_URL and GHOST_CONTENT_API_KEY values. These can be found in your production instance of Ghost once you deploy.

Conclusion

I hope you found this article helpful. I've been experimenting with Ghost a lot in the last few weeks and I hope to continue to build out my site to support some of its awesome features. Next I'll be writing up a tactic for integrating your JAMStack with Ghost's Members feature, so stay tuned for that! And as always, if you have questions feel free to reach out on Twitter.

Happy coding ⚡️

Jake Wiesler

Hey! 👋 I'm Jake

Thanks for reading! I write about software and building on the Web. Learn more about me here.

Subscribe To Original Copy

A weekly email for makers on the Web.

Learn More