DEV Community

Simon Pfeiffer for Codesphere Inc.

Posted on • Originally published at codesphere.com on

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

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

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?).

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);
    });
}
Enter fullscreen mode Exit fullscreen mode

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}
  }
}

Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)