Build a JAMStack Blog with NextJS and Ghost
Tags: nextjs, ghost, jamstack
Published: 2021-08-26 14:09:42

---
title: 'Build a JAMStack Blog with NextJS and Ghost'
publishedAt: '2021-08-17'
summary: Learn the fundamentals of JAMStack by building a blazing-fast NextJS blog that uses the Ghost publishing platform as a headless CMS.
tags: ['NextJS', 'Ghost', 'JAMStack']
published: true
canonical_url: https://www.jakewiesler.com/blog/ghost-nextjs-jamstack
---

I have a certain affinity for [Ghost](https://ghost.org/), an open-source publishing platform created in 2013 and built on [NodeJS](https://nodejs.org/en/). 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](https://ghost.org/changelog/4/), brings some awesome [new features](https://ghost.org/features/):

- A revamped Dashboard
- [Native support for Newsletters](https://ghost.org/docs/newsletters/)
- [Paid subscriptions](https://ghost.org/docs/members/#2-subscriptions)
- [Tons of integrations](https://ghost.org/integrations/)
- A cheaper Ghost(Pro) starter plan

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](https://ghost.org/docs/members/) 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](https://ghost.org/themes/), 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](https://ghost.org/docs/themes/).

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](https://ghost.org/docs/jamstack/), 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. **J**avaScript - build your front-end with a modern JavaScript framework.
2. **A**PIs - Query for your content with Ghost's APIs
3. **M**arkup - Write your content as markup in Ghost

In this post we'll use [NextJS](https://nextjs.org/) 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](https://ghost.org/docs/ghost-cli/). Open a terminal and install the latest version of [`ghost-cli`](https://github.com/TryGhost/Ghost-CLI) globally using your package manager of choice. I am a `yarn` man.

```bash
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`](https://ghost.org/docs/faq/node-versions/), their long term support version.

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

```bash
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](https://github.com/TryGhost/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](https://nextjs.org/docs/getting-started#setup). Again, with `yarn`:

```bash
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](https://ghost.org/docs/content-api/) and the [Admin API](https://ghost.org/docs/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:

```bash
yarn add @tryghost/content-api
```

If you're interested, here is [the documentation for the `@tryghost/content-api`](https://ghost.org/docs/content-api/javascript/).

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:

```bash
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:

```bash
cd lib
touch ghost.js
```

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

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

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

```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:

```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:

```markup
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:

```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:

```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:

```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](https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation) 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`](https://nextjs.org/docs/api-reference/next/link). For a list of all available values on a post coming from Ghost (hey that rhymes), see [here](https://ghost.org/docs/content-api/#posts).

## 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](https://nextjs.org/docs/routing/dynamic-routes), 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`](https://nextjs.org/docs/basic-features/data-fetching#getstaticpaths-static-generation). Here's how it works:

```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`:

```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](https://ghost.org/docs/content-api/#posts).

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

```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:

```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:

```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:

```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](https://ghost.org/docs/content-api/#tags).

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](https://nextjs.org/docs/api-reference/next/link):

```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:

```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.

```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:

```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:

```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:

```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:

```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:

```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:

```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](https://github.com/jakewies/next-ghost-demo). 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](https://vercel.com/home), 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](https://ghost.org/docs/hosting/) 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](https://ghost.org/docs/members/), so stay tuned for that! And as always, if you have questions feel free to reach out on [Twitter](https://twitter.com/jakewies).

Happy coding ⚡️