Building a Blog with Next.js and Ghost

emotionaldaffodil

roze šŸŒ¹

Posted on September 2, 2022

Building a Blog with Next.js and Ghost

Recently, I decided I'd like a blog for my portfolio.

One thing I wanted ā€“ to publish on any device. If I'm with my iPad at a coffee shop, I should be able to write a post, publish it, and have it appear on my website without writing any code.

So, I decided on the following stack

Let's get started shall we šŸ„ø

Deploying Ghost on Digital Ocean

Ghost offers a sleek blog editor similar to Medium.

Digital Ocean offers high availability with plenty of storage for a relatively low cost (you can also deploy your front end on here too if you'd like). There are free alternatives out there but I've faced some configuration and database challenges.

Prerequisite: you need a domain (you can get one on Namecheap for a decent price) or subdomain (not a subdirectory like www.domain.xyz/blog).

Create the Digital Ocean Ghost Droplet

  1. Create a Ghost Droplet on Digital Ocean
  2. Select Droplet Size (I did the cheapest on the Regular Intel SSD for about $6/month)
  3. Determine other configurations such as datacenter region which should be closest to your audience. I also chose SSH.
  4. Once created you'll have an IP address

Now install ghost by entering the server to complete the setup.

ssh root@use_your_droplet_ip
Enter fullscreen mode Exit fullscreen mode

You'll need to go to your domain name manager (e.g. Namecheap) and configure an A record using your droplet IP. You can also select to add a domain to Digital Ocean.

DNS Record ā€“ Add Your IP

When you ssh, you'll be prompted for

  1. The blog URL that you set up with the IP
  2. An email (for SSL)

Your blog should now be live at that URL

Ghost Configuration

Navigate to your blog URL and configure your account.

Set your account to private since the front end will be built using Next.js (Settings > General)

Make Site Private in Ghost

Setting up Next.js and Tailwind

Configure Next.js

npx create-next-app@latest --ts
Enter fullscreen mode Exit fullscreen mode

Follow this guide to quickly configure Tailwind CSS.

Do an npm run dev and your website should be live at localhost:3000/

Next.js Website Starter

Getting Blog Posts to Show Up

Let's get rid of most things in index.tsx and just have a blog title

import type { NextPage } from 'next';
import Head from 'next/head';

const Home: NextPage = () => {
  return (
    <div>
      <Head>
        <title>Welcome to My Blog</title>
        <link rel='icon' href='/favicon.ico' />
      </Head>

      <main>
        <h1 className='text-7xl p-4'>Blog</h1>
      </main>
    </div>
  );
};

export default Home;
Enter fullscreen mode Exit fullscreen mode

Blog Title

To interact with Ghost, we'll need to use its API. We can use their /posts/ endpoint to grab our blog posts.

We need to store two variables

  1. Content API Key
  2. Blog URL

Navigate to Settings > Integrations > Add Custom Integration

Add Custom Integration in Ghost

In your Next app, create .env.local and add your two variables

CONTENT_API_KEY=<your-key>
BLOG_URL=https://blog.domain.dev
Enter fullscreen mode Exit fullscreen mode

Create a new file lib/ghost.ts

  • Import your two environment variables
  • fetch the /posts endpoint (with some additional fields)
  • Return the result
const { CONTENT_API_KEY, BLOG_URL } = process.env;

export async function getPosts() {
  const res: any = await fetch(
    `${BLOG_URL}/ghost/api/v3/content/posts/?key=${CONTENT_API_KEY}&fields=title,slug,custom_excerpt,feature_image,reading_time,published_at,meta_title,meta_description&formats=html`
  ).then((res) => res.json());

  const posts = res.posts;

  return posts;
}
Enter fullscreen mode Exit fullscreen mode

Now we can use getStaticProps to add our posts as props to our page to render.

I also took a look at the object returned from getPosts to create some typings.

import type { NextPage } from 'next';
import Head from 'next/head';
import { getPosts } from '../lib/ghost';
import { GetStaticProps } from 'next/types';

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

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

  return {
    props: { posts },
    revalidate: 120, // in secs, at most 1 request to ghost cms backend
  };
};

interface IBlogProps {
  posts: Post[];
}

export type Post = {
  title: string;
  slug: string;
  custom_excerpt: string;
  feature_image: string;
  html: string;
  reading_time: number;
  published_at: Date;
  meta_title: string;
  meta_description: string;
};

const Home: NextPage<IBlogProps> = ({ posts }) => {
  return (
    <div>
      <Head>
        <title>Welcome to My Blog</title>
        <link rel='icon' href='/favicon.ico' />
      </Head>

      <main>
        <h1 className='text-7xl p-4'>Blog</h1>
        <ul>
          {posts.map((post) => (
            <li key={post.slug} className='px-4 py-2'>
              {post.title}
              {/** Add a Divider line */}
              {post.slug !== posts[posts.length - 1].slug && (
                <div className='relative flex py-5 items-center'>
                  <div className='flex-grow border-t border-gray-300 mr-24'></div>
                </div>
              )}
            </li>
          ))}
        </ul>
      </main>
    </div>
  );
};

export default Home;
Enter fullscreen mode Exit fullscreen mode

Blog with Post Titles

Let's stylize the titles a bit more by refactoring the code into its own component.

Create a new file /components/blogCard.tsx

import Link from 'next/link';
import type { Post } from '../pages/index';

interface IBlogCardProps {
  post: Post;
}

const BlogCard = ({ post }: IBlogCardProps) => {
  const { title, slug, reading_time, published_at } = post;

  const options: Intl.DateTimeFormatOptions = {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  };

  return (
    <div>
      <Link href='/post/[slug]' as={`/post/${slug}`}>
        <a className='md:text-2xl text-xl font-bold hover:text-indigo-200 transition duration-300'>
          {title}
        </a>
      </Link>
      <div className='md:flex'>
        <div className='flex pt-4'>
          <p className='italic md:text-sm text-xs'>
            šŸ—“ {new Date(published_at).toLocaleDateString('en-US', options)}
          </p>
          <p className='pl-7 text-gray-300 font-light md:text-sm text-xs'>
            {reading_time} min
          </p>
        </div>
      </div>
    </div>
  );
};

export default BlogCard;
Enter fullscreen mode Exit fullscreen mode

The Link element currently takes us to a dynamic route that we will need to set up in the next section.

Go back to index.tsx and replace {post.title} with the BlogCard component

...
<li key={post.slug} className='px-4 py-2'>
  <BlogCard post={post} />
...
Enter fullscreen mode Exit fullscreen mode

Blog with styling

Rendering a Single Blog Post

Our links currently don't take us anywhere. We need to grab blog content from Ghost at the /posts/slug/{slug}/ endpoint and render that content.

Let's add a new function getPost in ghost.ts to do exactly that.

...
export async function getPost(slug: string) {
  const res: any = await fetch(
    `${BLOG_URL}/ghost/api/v3/content/posts/slug/${slug}?key=${CONTENT_API_KEY}&fields=title,slug,custom_excerpt,feature_image,reading_time,published_at,meta_title,meta_description&formats=html`
  ).then((res) => res.json());

  const posts = res.posts;

  return posts[0];
}
Enter fullscreen mode Exit fullscreen mode

Create a new file pages/post/[slug].tsx that will represent a dynamic route where [slug] is a parameter. This route matches what we had in the Link element href.

In this file, we need to

  • Define getStaticProps that grabs our post content
  • Define getStaticPaths to generate static paths for the dynamic route
  • Load the blog content
import { useRouter } from 'next/router';
import { GetStaticPaths, GetStaticProps, NextPage } from 'next/types';
import { ParsedUrlQuery } from 'querystring';
import { Post } from '..';
import { getPost, getPosts } from '../../lib/ghost';

interface IContextParams extends ParsedUrlQuery {
  slug: string;
}

export const getStaticProps: GetStaticProps = async (context) => {
  const { slug } = context.params as IContextParams;
  const post: string = await getPost(slug);

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

  return {
    props: { post },
    revalidate: 120, // in secs, at most 1 request to ghost cms backend
  };
};

export const getStaticPaths: GetStaticPaths<IContextParams> = async () => {
  const posts = await getPosts();

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

  return { paths, fallback: 'blocking' };
};

interface ISlugPostProps {
  post: Post;
}

const Post: NextPage<ISlugPostProps> = ({ post }) => {
  const router = useRouter();

  if (router.isFallback) {
    return <LoadingPage />;
  }

  return <BlogContent post={post} />;
};

const LoadingPage = () => {
  return (
    <div className='flex items-center justify-center'>
      <h1 className='md:text-5xl text-3xl md:pb-12 pb-8'>Loading...</h1>
    </div>
  );
};

const BlogContent = ({ post }: ISlugPostProps) => {
  const { title, published_at, reading_time, html } = post;

  const options: Intl.DateTimeFormatOptions = {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  };

  return (
    <article className='flex flex-col items-start justify-center w-full max-w-2xl mx-auto'>
      {/** TITLE */}
      <div className='flex items-center justify-center'>
        <h1 className='md:text-5xl text-3xl md:pb-12 pb-8 pt-4'>{title}</h1>
      </div>
      {/** DATE + READING TIME */}
      <div className='flex pb-6'>
        <p className='italic px-3 tag'>
          šŸ—“ {new Date(published_at).toLocaleDateString('en-US', options)}
        </p>
        <p className='pl-7 text-gray-300 font-light md:text-sm text-xs'>
          {reading_time} min
        </p>
      </div>
      {/** CONTENT */}
      <section>
        <div dangerouslySetInnerHTML={{ __html: html }}></div>
      </section>
    </article>
  );
};

export default Post;
Enter fullscreen mode Exit fullscreen mode

Now when we click a blog post, we're taken to a separate page and can see the content.

It doesn't look great though...

Styling Blog Post

Using Chrome Developer Tools, we can inspect the HTML of our blog post and see what classes are being used, and can style them. By default, Tailwind gets rid of styles for typical <h1>, <h2>, etc elements so we have to define that ourselves.

Create a new file styles/BlogPost.module.css and we can style the elements to our liking.

.postFullContent {
    min-height: 230px;
    line-height: 1.6em;

    @apply text-lg relative
}

.postFullContent a {
    color: black;
    box-shadow: inset 0 -1px 0 black;

    @apply hover:text-indigo-200 transition duration-300
}

.postContent {
    @apply flex flex-col items-center
}

.postContent p {
    @apply mb-6 min-w-full
}

.postContent h2 {
    line-height: 1.25em;
    @apply md:text-3xl text-xl font-semibold mx-0 my-2 min-w-full
}

.postContent h3 {
    line-height: 1.25em;
    @apply md:text-2xl text-lg font-semibold mx-0 mt-2 mb-1 min-w-full
}

.postContent ol {
    list-style: auto;
    padding: revert;
    @apply mb-6 min-w-full
}

.postContent ul {

    list-style: disc;
    padding: revert;
    @apply mb-6 min-w-full
}

.postContent li {
    word-break: break-word;
    line-height: 1.6em;
    @apply my-2 pl-1
}

.postContent blockquote {
    margin: 0 0 1.5em;
    padding: 0 1.5em;
    border-left: 3px solid #D4C3F9;
    @apply min-w-full
}

.postContent code {
    color: #fff;
    background: #000;
    border-radius: 3px;
    padding: 0 5px 2px;
    line-height: 1em;
    font-size: .8em;
}

.postContent pre {
    overflow-x: auto;
    margin: 1.5em 0 3em;
    padding: 20px;
    max-width: 100%;
    border: 1px solid #000;
    color: #e5eff5;
    background: #0e0f11;
    border-radius: 5px;

    @apply min-w-full
}

.postContent pre > code {
    background: transparent;
}

.postContent figure {
    display: block;
    padding: 0;
    border: 0;
    font: inherit;
    font-size: 100%;
    vertical-align: baseline;
    @apply mt-3 mb-9
}

.postContent figure figcaption {
    @apply font-light text-sm text-center my-4
}

.postContent em {
    @apply font-medium
}
Enter fullscreen mode Exit fullscreen mode

Add these styles to pages/post/[slug].tsx

...
import styles from '../../styles/BlogPost.module.css';
...
{/** CONTENT */}
<section className={styles.postFullContent}>
  <div
    className={styles.postContent}
    dangerouslySetInnerHTML={{ __html: html }}
  ></div>
</section>;
Enter fullscreen mode Exit fullscreen mode

Some things need to be styled at the global level, however.

Using the basic Ghost theme, I had to add the following to styles/globals.css

...
.kg-bookmark-container {
    color: black;
    min-height: 148px;
    border-radius: 3px;

    @apply flex md:flex-row flex-col 
}

a.kg-bookmark-container {
    word-break: break-word;
    transition: all .2s ease-in-out;
    background-color: transparent;
    @apply border-2 border-black
}

.kg-bookmark-content {
    align-items: flex-start;
    padding: 20px; 

    @apply flex flex-grow flex-col justify-start md:order-none order-2
}

.kg-bookmark-title {
    line-height: 1.5em;
    @apply hover:text-indigo-200 transition duration-300 font-semibold text-[#372772]
}

.kg-bookmark-description {
    color: black;
    display: -webkit-box;
    overflow-y: hidden;
    margin-top: 12px;
    max-height: 48px;
    color: #5d7179;
    font-size: 12px;
    line-height: 1.5em;
    font-weight: 400;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
}

.kg-bookmark-metadata {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    margin-top: 14px;
    color: #5d7179;
    font-size: 1.5rem;
    font-weight: 400;
}

.kg-bookmark-icon {
    margin-right: 8px;

    @apply md:w-[22px] md:h-[22px] w-[18px] h-[18px]
}

.kg-bookmark-publisher {
    overflow: hidden;
    max-width: 240px;
    line-height: 1.5em;
    text-overflow: ellipsis;
    white-space: nowrap;
    @apply text-sm font-light
}

.kg-bookmark-thumbnail {
    position: relative;
    min-width: 33%;
    max-height: 100%;
    @apply min-h-[160px]
}

.kg-bookmark-thumbnail img {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    border-radius: 0 3px 3px 0;
    -o-object-fit: cover;
    object-fit: cover;
    display: block;
}

.kg-image {
    max-width: 100%;
}

.kg-gallery-container {
    display: flex;
    flex-direction: column;
    max-width: 1040px;
    width: 100vw;
}

.kg-gallery-row {
    display: flex;
    flex-direction: row;
    justify-content: center;
}

.kg-gallery-image:not(:first-of-type) {
    margin: 0 0 0 0.75em;
}

.kg-gallery-image {
    flex: 1.5 1 0%;
}
Enter fullscreen mode Exit fullscreen mode

Styled Blog Post

Deploying on Vercel

To deploy your blog, we can use Vercel for free.

  1. Push your code to GitHub
  2. Create a Vercel account with GitHub https://vercel.com/signup
  3. Import your blog
  4. Add your environment variables to Vercel (since they aren't pushed through Git)

Now, anytime you make a code change and push it to your main branch, Vercel will automatically deploy that new change.

You won't need to make a code change if you add a new blog post though, it'll automatically appear!

Peace ā€“ have fun writing āœŒšŸ½

Say Hi šŸ‘‹šŸ¼
šŸ¦ Twitter
šŸ‘©šŸ½ā€šŸ’» Github

Buy Me A Coffee

šŸ’– šŸ’Ŗ šŸ™… šŸš©
emotionaldaffodil
roze šŸŒ¹

Posted on September 2, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Building a Blog with Next.js and Ghost