Building a Blog with Next.js and Ghost
Tags: nextjs, tutorial, react, typescript
Published: 2022-09-02 04:18:15

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
- [Next.js](https://nextjs.org/) – to build our frontend
- [Tailwind CSS](https://tailwindcss.com/) – to style our frontend
- [Vercel](https://vercel.com/) – to deploy our frontend
- [Ghost](https://ghost.org/) – to write our posts as a headless CMS
- [Digital Ocean](https://www.digitalocean.com/) – to deploy Ghost

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](https://marketplace.digitalocean.com/apps/ghost)
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.

```zsh
ssh root@use_your_droplet_ip
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/g5v2ansb5medwd0qplem.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/eo6lo0zfoxfozc2lmpzj.png)


## Setting up Next.js and Tailwind

Configure Next.js

```zsh
npx create-next-app@latest --ts
```

Follow this [guide](https://tailwindcss.com/docs/guides/nextjs) to quickly configure Tailwind CSS.

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


![Next.js Website Starter](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fsv67lox5rrn8smclvjh.png)


## Getting Blog Posts to Show Up

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

```typscript
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;
```


![Blog Title](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sbwkm8j9er0f0xkwv9bp.png)

To interact with Ghost, we'll need to use its [API](https://ghost.org/docs/content-api/). We can use their [`/posts/`](https://ghost.org/docs/content-api/#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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/83m707wz6n5dht50ck85.png)


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

```
CONTENT_API_KEY=<your-key>
BLOG_URL=https://blog.domain.dev
```

Create a new file `lib/ghost.ts`

- Import your two environment variables
- `fetch` the `/posts` endpoint (with some additional fields)
- Return the result

```typescript
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;
}
```

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.

```typescript
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;
```


![Blog with Post Titles](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rqtzp8og6pjonvq1shrh.png)


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

Create a new file `/components/blogCard.tsx`

```typescript
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;
```

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

```typescript
...
<li key={post.slug} className='px-4 py-2'>
  <BlogCard post={post} />
...
```


![Blog with styling](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/o2q15iokuyxyhrh1qpuk.png)


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

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

```typescript
...
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];
}
```

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

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

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

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

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

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`

```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%;
}
```


![Styled Blog Post](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/carj9o9igw5wcy08v5m5.png)

## 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](https://twitter.com/emotionldaffodl)
👩🏽‍💻 [Github](https://github.com/elewis9989)

[![Buy Me A Coffee](https://cdn.buymeacoffee.com/buttons/default-yellow.png)](https://www.buymeacoffee.com/roze)