Building a headless frontend for Ghost.io CMS with Next.js

Can you build a headless frontend for a ghost.io blog cms in a single day? Yes! Follow along to learn how to build it yourself.

July 17, 2023 9 Min Read
Building a headless frontend for Ghost.io CMS with Next.js
Building a headless frontend for Ghost.io CMS with Next.js

Codesphere

From everyone in the Codesphere Team:)

Table of Contents

We have been working on simplifying the technology stack we use in our marketing team at Codesphere. At first we switched from complex build heavy pug scss compiled websites to standalone, static html css javascript landing pages. It made us a lot faster in shipping & testing improvements.

So we've been applying this approach to other areas as well, separating more and more of our funnel pieces into components and pulling these micro frontends together in an aggregation layer. In todays post I will show you how we built a simple and fast headless frontend usable for any ghost.io blog content management system using next.js - and the best thing it only takes 1-2 days of development from scratch.

In part 1 we will build the basic infrastructure to connect our ghost CMS with a headless next.js, style the index page and set the ground for filtering content categories. Part 2 will introduce caching middleware (for speed improvements), populating previews by category and a load more function. The goal is to hit a mobile page-speed score of at least 90 - let's see if we can make that work.

We will deploy this standalone blog on codesphere and I will make the entire code available once it's finished, so stay tuned for part 2 (and maybe 3?).  

Zero config cloud made for developers

From GitHub to deployment in under 5 seconds.

Sign Up!

Review Faster by spawning fast Preview Environments for every Pull Request.

AB Test anything from individual components to entire user experiences.

Scale globally as easy as you would with serverless but without all the limitations.

Step 1 - Create your Next.Js app

You can simply start with npx create-next-app@latest in your terminal but I decided to try something else - have you heard of GitWit already? Recently AI has been revolutionizing a lot of fields and chatPGTs coding skills have been all over the news.

GitWit is a wrapper that allows you to generate code templates with AI, you simply say in your own words the type of changes you want to make to your repository and it will create a pull request for you to review. While it won't build the full app for you (yet) it's a great starting point for projects like this one. Try it out here.

In order to interact with our ghost.io content management backend we first need to install their content api via npm install @tryghost/content-api . In order to keep our code organized we will create a separate file in src/pages/api and call in ghost.js. In the most simple form this file is only going to be 30 lines long and contain two functions - one for fetching the blog previews and one for fetching a full post.

You will need to make sure you place your ghost.io content url and api key as environment variables - if you need to create them still, read more here.

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

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

export async function getPostsPreview() {
  return await api.posts
    .browse({
      include: 'tags',
      fields: 'id,slug,title,feature_image,published_at,primary_tag,excerpt',
    })
    .catch(err => {
      console.error(err);
    });
}


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

Designing our blog home page

The landing page of our blog frontend needs to satisfy a few things that can be challenging to align.

  1. We want to display previews of the latest blog posts & get people interested to read more
  2. We want it to load super fast (page speed score >90 on mobile) in order to not miss out on SEO potential
  3. (Part 2) We want to allow viewing blog previews by category and load only the relevant posts at first

The challenge here is that we want to display rich content and preview images to generate interest among our visitors but we want to keep the data needed super minimal, the design lean in order for the page to load fast.

Since we are using next.js we also want to utilize the ability to get static props once and not for each individual request. For that reason we are going to call the getPostsPreview wrapped in next.js getStaticProps call.

With our posts object we will then call our home function and use, .map to cast the individual previews into our html layout. We also added some elements that we will use later to filter by category - for part 1 they will not have any functionality yet. For the sake of speed we only display a preview image for the latest post and highlight that specifically.

import { getPostsPreview } from '../pages/api/ghost'
import Head from 'next/head';
import Link from 'next/link';
import Image from 'next/image'
import { useRouter } from 'next/router';


export default function Home({ posts }) {
  const {
    asPath,
  } = useRouter();
  return (
    <>
      <Head>
        <title>Create Next App</title>
      </Head>
      <main>
        <h1>Insights and updates from across the team</h1>
        <div className="category-buttons">
          <Link href="/" className={asPath.includes('?category') ? 'filter-category' : 'active filter-category'} >Recent Articles</Link>
          <Link href="/?category=Tutorials" className={asPath.includes('Tutorials') ? 'active filter-category' : 'filter-category'}>Tutorials</Link>
          <Link href="/?category=Informative" className={asPath.includes('Informative') ? 'active filter-category' : 'filter-category'}>Informative</Link>
          <Link href="/?category=Discussion" className={asPath.includes('Discussion') ? 'active filter-category' : 'filter-category'}>Discussion</Link>
          <Link href="/?category=CompanyNews" className={asPath.includes('CompanyNews') ? 'active filter-category' : 'filter-category'}>Company News</Link>
        </div>
        <div className="previews">
          
          {posts.map((post) => (
            <Link href={`/${post.slug}`} key={post.id} id={post.id}>
              <div className="post-preview">
                <div>
                  <div className="row">
                    <p className="tag" data-results={post.primary_tag.name}>{post.primary_tag.name}</p>  
                    <p>🗓️ {post.dateFormatted}</p> 
                  </div>

                  <h3>{post.title}</h3>
                  <div className="info">      
                    <p>{post.excerpt}</p>
                  </div>
                </div>
                { post.id == posts[0].id
                  ? 
                  <Image
                    src= {post.feature_image}
                    width={374}
                    height={291}
                    alt={`Preview for ${post.title}`}
                  />
                  : ''
                }
               
              </div>
            </Link> 
          ))}
        </div>
        <a href=''>Load more</a>  
        <div className="backgroundBlur1"></div>
        <div className="backgroundBlur2"></div>        
      </main>
    </>
  );
}


export async function getStaticProps() {
  const posts = await getPostsPreview()
  posts.map(post => {
    const options = {
      year: 'numeric',
      month: 'short',
      day: 'numeric'
    };
  
    post.dateFormatted = new Intl.DateTimeFormat('en-US', options)
      .format(new Date(post.published_at));
  });
  if (!posts) {
    return {
      notFound: true,
    }
  }

  return {
    props: { posts}
  }
}

Of course we cannot leave it without styling. I'll just drop the css here as I do not want to go into too much detail in this post.

:root {
  --max-width: 1100px;
  background: #110e27;
  font-family: Arial, sans-serif;
}
h1 {
  color: #fff;
  text-align: center;
}
h2 {
  color: #fff;
  text-align: center;
}
h3 {
  margin-bottom: 0px;
  color: #ffffff;
}

p {
  color: #938CA7;
}

a {
  text-decoration: none;
  color: #814BF6;
}

img {
  max-width: 100%;
  height: auto;
}

.category-buttons {
  display: flex;
  column-gap: 20px;
  justify-content: center;
  margin-bottom: 20px;
}

.filter-category {
  border-radius: 20px;
  min-height: 30px;
  background-color: transparent;
  color: #fff;
  padding: 4px 16px;
  align-items: center;
  justify-content: center;
  display: inline-flex;
}

.filter-category.active {
  background-color: rgba(129, 75, 246, 0.3);
}

.tag {
  font-size: 0.8rem;
  height: 24px;
  border-radius: 12px;
  background-color: #814BF6;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 2px 12px;
  color: #fff;
}
.tag[data-results="Informative"] {
  background-color: #ff980e;
}
.tag[data-results="Discussion"] {
  background-color: #1fb881;
}
.tag[data-results="Company news"] {
  background-color: #00bcff;
}

.info {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding-top: 16px;
  margin-top: 8px;
  border-top: 1px solid rgba(65, 57, 134, 0.5);
}

.row {
  display: flex;
  column-gap: 20px;
}


.previews {
  max-width: 800px;
  margin: 0 auto;
  display: grid;
  grid-template-columns: 1fr 396px;
  grid-template-rows: repeat(2, 1fr);
  gap: 30px;
}


.previews a:first-child {
  grid-column: 1/3;
  grid-row: 1;  
}
.previews a:first-child .post-preview{
 display: grid;
 grid-template-columns: 2fr 1fr;
}

.post-preview {
  align-items: center;
  padding: 20px 22px;
  background: rgba(255, 255, 255, 0.03);
  border-radius: 16px;
  overflow: hidden;
  position: relative;
  min-height: 320px;
  align-items: start;
}

.post-preview:hover {
  background: rgba(255, 255, 255, 0.05);
  transition: 0.3s; 
}

.backgroundBlur1 {
  position: absolute;
  top: 10%;
  left: 20%;
  opacity: 0.2;
  z-index: -1;
  width: 600px;
  height: 600px;
  max-width: 50%;
  max-height: 50%;
  border-radius: 50%;
  overflow: hidden;
  background: radial-gradient(#7821A0 1%, #110e27 100%);
  box-shadow:
    0 0 50px #110e27,            /* outer dark */
    -10px 0 80px #7821A0,        /* outer left magenta */
    10px 0 80px #110e27;         /* outer right dark */
}

.backgroundBlur2 {
  position: absolute;
  top: 50%;
  right: 20%;
  opacity: 0.1;
  z-index: -2;
  width: 800px;
  height: 800px;
  max-width: 50%;
  max-height: 50%;
  border-radius: 50%;
  overflow: hidden;
  background: radial-gradient(#00BCFF 1%, #110e27 100%);
  box-shadow:
    0 0 50px #110e27,            /* outer white */
    -10px 0 80px #00BCFF,        /* outer left magenta */
    10px 0 80px #110e27;         /* outer right cyan */
}

@media (max-width: 991px) {
  p, h1, h2, h3 {
    text-align: center;
  }
  .previews {
    display: block;
  }
  .post-preview{
    margin-top: 30px;
  }
  .previews a:first-child .post-preview{
    display: block;
  }
  .category-buttons {
    flex-direction: column;
  }  
}

.container {
  max-width: 900px;
  margin: auto;
  padding: 20px;
  background: rgba(255, 255, 255, 0.883);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 20px;
}

.navigation {
  display: flex;
  column-gap: 20px;
  align-items: center;
  justify-content: center;
}

Populating the actual pages for each article

For this we make use of next.js dynamic routing capabilities. We will create a file called [slug].js which contains the layout for each post page.

Besides the layout we will need to call two functions, the first one wrapped in getStaticPaths() - it will call the getPreviews() function we defined in ghost.js and returns the slugs as paths.

Secondly we will call the getSinglePost() function to return a the full post for this specific slug - we will use this to populate the template.

import Head from 'next/head';
import Link from 'next/link';
import { getSinglePost, getPostsPreview } from '../pages/api/ghost'

export default function BlogPost({ post }) {
  return (
    <>
      <Head>
        <title>{post.title}</title>
      </Head>
      <main>
        <div className='container'>
          <div className='navigation'>
            <Link href={"/"}>Blog</Link>
            <p>/</p>
            <p>{post.primary_tag.name}</p>
          </div>
          <h1>{post.title}</h1>
          <div dangerouslySetInnerHTML={{ __html: post.html }} />
        </div>
        <div className="backgroundBlur1"></div>
        <div className="backgroundBlur2"></div>   
      </main>
    </>
  );
}

export async function getStaticPaths() {
  const posts = await getPostsPreview()

  // Get the paths we want to create based on posts
  const paths = posts.map((post) => ({
    params: { slug: post.slug },
  }))

  // { fallback: false } means posts not found should 404.
  return { paths, fallback: false }
}


// Pass the page slug over to the "getSinglePost" function
// In turn passing it to the posts.read() to query the Ghost Content API
export async function getStaticProps(context) {
  const post = await getSinglePost(context.params.slug)

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

  return {
    props: { post }
  }
}

And that's it for part 1 - you should now have a running version of an admittedly still rather simple headless frontend for your ghost.io blog posts.

Outlook part 2

Now everything we have built so far works but it not optimized for speed - currently we are pulling up to 15 posts and just passing them to the frontend. Depending on the size of the images in there you will already be receiving warnings that the recommend maximum network load is exceeded by our index page. To counteract this we will be implementing a middleware in part 2 - we pull and store the posts on a scheduler outside of the main thread and store them in a local SQLite or similar before pulling only the bare minimum into the frontend.

Also we still need to add functionality to display posts by category and something I realized only later, we want our full page posts to support things like code embeds and automatically resized optimized images - so we will be adding that too.

About the Author

Building a headless frontend for Ghost.io CMS with Next.js

Codesphere

From everyone in the Codesphere Team:)

We are building the next generation of Cloud, combining Infrastructure and IDE in one place, enabling a seamless DevEx and eliminating the need for DevOps specialists.

More Posts

Cloud Native Meetup Recap

Cloud Native Meetup Recap

Karlsruhe offers a vibrant tech scene and we are proud to be part of a group organizing expert & community meetups like this one.

Full Metal

Full Metal

Buying a used server on ebay kleinanzeigen and preparing it to be cloudified? Follow along to see what it takes to get a piece of metal running.