DEV Community

Cover image for How to Create a Blog Using Next.js and Contentful CMS
Jay @ Designly
Jay @ Designly

Posted on • Updated on • Originally published at blog.designly.biz

How to Create a Blog Using Next.js and Contentful CMS

In case you haven't heard, Next.JS is a React JS framework that super-optimizes website page speeds. When you host your Next website on Vercel's platform (for free), you get their automatic image optimization as well when you use Next's Next/Image built-in component.

Another key feature of Next is the ability to generate static pages from external data sources using the getStaticProps() function. This dramatically speeds up data-driven websites, such as blogs because there is no back-end call to a server or CMS when visitors come to your site. The data is pulled from the database or CMS at build-time.

Contentful is a headless content management system (CMS). Headless simply means there is no front-end to display the content to the consumer. It's basically a database, but much easier to setup and maintain than a traditional relational database. Contentful provides a very easy-to-use API for fetching and managing content. They also support GraphQL queries if you're into that.

Contentful's paid plan is quite pricey (nearly $500/mo), but the free (community plan) is very generous and should suit most small to medium size projects. If you want all the details, please check out Contentful's Technical Limits for their free tier.

Contentful has two SDKs that are available for integration with React/Next. The first is their Content Delivery API (CDA). And the second is their Content Management API (CMA). The first is a read-only API that's used for delivering content to your front-end. The second is read/write and allows creating, editing and managing content and content models programmatically.

Unlike a database, Contentful is object-driven. Contentful calls data objects Content Models. You can consider a content model a type of document. In this tutorial, we are going to create two types of content models: Blog Post and Author.

To create our blog, we're going to need the following prerequisites:

  • A free-forever Contentful account
  • A free-forever Vercel account
  • A Github account (free as well)
  • Node.JS and NPM installed on your PC
  • A development IDE, such as Visual Studio Code (also free)

The complete repository for this tutorial can be found Here.

And a demo of this tutorial app can be found Here

Ok, let's get started!

Step 1: Set Up Github

If you're a seasoned developer, you probably already have a Github account. If not head over there now and set one up. Github is a development collaboration and code-management / versioning system. Vercel will automatically rebuild your Next.JS website when you push a new commit of your code to Github.

I also recommend using the Github Desktop app, which is great for visualizing changes bewtween commits. If you're already a seasoned command-line user, then I understand! :-)

Step 2: Set Up Contentful

Head over to Contentful.com and register for a free account. No credit card needed. You can use your Github account to register and sign-in. Once you have your account registered and you're logged in, you should be at your Contentful Space Dashboard.

Ok, first we want to create our dependency content models before we create the actual blog post model. This is because Blog Post will refer to Author and Category much like a relational database.

Begin by clicking the Content Model tab at the top of the dashboard. Then click the Add Content Type button in the upper right corner. Let's name it Author. Note that the API Identifier field is automatically filled in. You can type in a friendly name (with spaces) as the field name and Contentful will automatically generate the variable name in camel case, which is how we will refer to the field programmatically.

Create Author Content Type

Now let's create some fields. Click the Add Field button to the right and select Text as the field type. Name the field Name and then click Create and Configure.

Create a Name Field

Under Field Options check the box labeled This field represents the Entry title. Then under the Validation tab check Required Field.

Configure the Name Field

Next, repeat the same process and add a field named Image. Select Media as the field type. Leave One File checked, as an author will only have one profile image, and then click Create. When you're done click the Save button in the upper right corner.

Now let's create a content model called Category. This model will have two Short Text fields: Category Name and Description. Be sure to check Category Name as the entry title.

Ok now we can finally create our Blog Post content model. Blog post will have the following fields:

Field Name Type Settings
Title Short Text ✅Represents title field
Publish Date Date & Time
Slug Short Text Appearance Tab: Generate slug from Title
Featured Image Media ✅One File
Author Reference Validation: Accept Only Entry Type: Author
Category Reference Validation: Accept Only Entry Type: Category
Excerpt Long Text
Content Long Text

Note that the Slug field will automatically be generated from the Title field. The slug field will be used for generating our static routes in Next. For example, if we had a post titled All Your Base Are Belong to Us, the slug would be all-your-base-are-belong-to-us, which is a URL-safe format.

Step 3: Create a Test Post

First, create an author entry. Under the Content tab, click the Add dropdown menu in the upper right and select Author. Enter your name and upload your profile pic. You can also use Contentful's built-in editing tools to crop or rotate your image as well. You can enter a title and description, but it's not necessary. We will use the Name field of the author entry for the <img alt="" /> property in our Next app.

Upload and Crop Image

Next, we'll need a category. Again, click the Add dropdown and select category. I'm going to name mine General. You can add as many categories as you like, but we'll start with just one for testing purposes.

And now we can finally create our test post. Fill out all the fields and upload a Featured Image. Your featured image should be roughly 1920x1200 pixels. Next.JS will automatically optimize and render the <img src-set="" />. For Excerpt, you should type an SEO-friendly synopsis of what your blog post is about.

In the content field, I've added an image as well. You can use the Insert Media tool to upload images directly to Contentful's CDN, but you are limited to image size and bandwidth per month. If you plan to have a lot of images in our blog posts, I might suggest using a CDN service or an S3 bucket to store images. You might also want to check out my article titled How to Use AWS CloudFront to Create Your Own Free CDN.

You should end up with something like this:

Test Post Example

When you're done, click Publish.

Great. Now for the fun part!

Step 4: Create Our Blog App

Ok, so hopefully you have the latest versions of Node.JS and NPM installed. This tutorial doesn't cover that, but here's a point in the right direction. Personally, I prefer Ubuntu for my React development environment, but Node will run on Windows or Mac as well. I think you need to use Homebrew to install it on MacOS.

We also need to create our Github repository. Hopefully you've downloaded and installed Guthub Desktop and are logged in to your Github account.

From file File menu, choose New Repository. Choose a name for your repository (usually lowercase with dashes). I chose contentful-next-blog for this tutorial. Then you can either choose a location for your respository or you can keep the defaults, which is Home_Dir > Documents > GitHub. You don't need to worry about a .gitignore file. NPM will create one for us when we initialize our app. Also, do not choose to create a README.md file as it will conflict with the create-next-app command. Once it's created, click the Publish Repository button. You should leave Keep this code private unless you want your code to be publicly visible.

Now, when ever you make any local changes to your reponsitory, you can come back here and see the changes that have been made. You can also discard changes as well, which makes Github like a super-duper undo button!

Create Your Github Repository


Next, open up your terminal, depending on your operating system and navigate to your Github root directory (usually HOME_DIR/Documents/GitHub), just outside your repository directory. Enter the following command:

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

It should install in a few seconds. Now let's test it:

cd contentful-next-blog
npm run dev
Enter fullscreen mode Exit fullscreen mode

You should see an output like this:

ready - started server on 0.0.0.0:3000, url: http://localhost:3000
wait  - compiling...
event - compiled client and server successfully in 1331 ms (125 modules)
Enter fullscreen mode Exit fullscreen mode

Hit CTRL+C to exit the development server. Now we need to install some additional packages for your blog:

npm install nextjs-progressbar next-sitemap react-markdown react-syntax-highlighter rehype-raw
npm install remark-gfm sweetalert2 contentful react-bootstrap bootstrap
Enter fullscreen mode Exit fullscreen mode

Here's a brief summary of the packages we're going to use:

Package Name Purpose
nextjs-progressbar Shows a progress bar at the top of the page during page loads
next-sitemap Automatically generates a sitemap.xml for statically-generated pages
react-markdown Renders our blog content's markdown as HTML
react-syntax-highlighter Highlights common programming syntax
rehype-raw Supports HTML embedded in markdown
remark-gfm Adds Github-like markdown support (e.g. tables)
sweetalert2 A very nice modal / alert system that we'll use for popup images
contentful Contentful Delivery API SDK for fetching our blog content
react-bootstrap The UI framework we'll use for the demo, but you can use whatever you like

Ok now we need to set up our environment variables for development. In the root directory of your project, create a file called .env.local. This file will store our contenful API key and space ID. Next, go back to your contentful dashboard, click the Settings dropdown at the top and select API Keys. Then click Add API Key.

Create a Contentful API Key

Now copy the space ID and access token to your .env.local file like so:

NEXT_PUBLIC_CONTENTFUL_SPACE_ID=YOUR_SPACE_ID
NEXT_PUBLIC_CONTENTFUL_ACCESS_TOKEN=YOUR_TOKEN
NEXT_PUBLIC_BASE_URL=http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

On a side note, apps created by create-next-app must have environment variables prefixed by NEXT_PUBLIC_. Also, NEXT_PUBLIC_BASE_URL will be used by next-sitemap.

Now save your file and go back to the terminal and run:

npm run dev
Enter fullscreen mode Exit fullscreen mode

In your browser, nagivate to http://localhost:3000. You should see a page like this:

Default Next.JS Page

Now we need to edit our main page wrapper pages/_app.js to include bootstrap and nextjs-progressbar, as well as import our styles (which we'll create in a minute):

import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap/dist/css/bootstrap.min.css';
import '../styles/globals.css'
import '../styles/blogPost.css'
import '../styles/postList.css'
import NextNProgress from "nextjs-progressbar";

function MyApp({ Component, pageProps }) {
  return (
    <>
      <NextNProgress height={10} color="#2c85ba" />
      <Component {...pageProps} />
    </>
  )
}

export default MyApp
Enter fullscreen mode Exit fullscreen mode

Now we need to configure next-sitemap to generate our blog's sitemap.xml. Create a new file in the root directory called next-sitemap.config.js.

// next-sitemap.config.js

/** @type {import('next-sitemap').IConfig} */

const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://example.com";

const fs = require('fs');

let postsMeta = fs.readFileSync('./public/blogPostMeta.json');
postsMeta = JSON.parse(postsMeta);

module.exports = {
    siteUrl: baseUrl,
    generateRobotsTxt: true,
    changefreq: 'monthly',
    transform: async (config, path) => {
        let additionalProps = {};

        if (path.match(/blog\/post/)) {
            const pathAr = path.split("/");
            const findSlug = pathAr.pop();

            const post = postsMeta.find( ({ slug }) => slug === findSlug );
            if (post) {
                additionalProps = {
                    'image:image': post.coverImage
                };
            }
        }

        return {
            loc: path,
            changefreq: config.changefreq,
            priority: config.priority,
            lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
            ...additionalProps
        };
    },
}
Enter fullscreen mode Exit fullscreen mode

You may be wondering about the additional configuration options. What this does is allows next-sitemap to add image tags to our blog post listings from a JSON file that we will generate later.

Now we need to add the post-build script to package.json:

 {
   "scripts": {
     "dev": "next dev",
     "build": "next build",
     "start": "next start",
     "lint": "next lint",
     "postbuild": "next-sitemap"
   }
 }
Enter fullscreen mode Exit fullscreen mode

Next, edit next.config.js in the root directory to look like this:

/* next.config.js */

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  images: {
    domains: [
      'images.ctfassets.net',
    ],
  }
}

module.exports = nextConfig
Enter fullscreen mode Exit fullscreen mode

This will allow next/image to load images from the Contentful CDN. Add any other image sources here.


And we'll create two style files in /styles:

/* postList.css */

@import url("https://fonts.googleapis.com/css2?family=Quicksand:wght@300..700&display=swap");

*,
*::before,
*::after {
    box-sizing: border-box;
    padding: 0;
    margin: 0;
}

.post-list-wrapper {
    font-family: "Quicksand", sans-serif;
    display: grid;
    place-items: center;
    height: 100vh;
    background: #7f7fd5;
    background: linear-gradient(to right, #91eae4, #86a8e7, #7f7fd5);
}

.post-list-container {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    max-width: 1200px;
    margin-block: 2rem;
    gap: 2rem;
    flex-direction: row;
}

img {
    max-width: 100%;
    display: block;
    object-fit: cover;
}

.post-card {
    display: flex;
    flex-direction: column;
    width: clamp(20rem, calc(20rem + 2vw), 22rem);
    overflow: hidden;
    box-shadow: 0 .1rem 1rem rgba(0, 0, 0, 0.1);
    border-radius: 1em;
    background: #ECE9E6;
    background: linear-gradient(to right, #FFFFFF, #ECE9E6);
    cursor: pointer;
    transition: all 0.3s ease-in-out;
}

.post-card:hover {
    opacity: 0.8;
}

.card__body {
    padding: 1rem;
    display: flex;
    flex-direction: column;
    gap: .5rem;
}

.tag {
    align-self: flex-start;
    padding: .25em .75em;
    border-radius: 1em;
    font-size: .75rem;
}

.tag+.tag {
    margin-left: .5em;
}

.tag-blue {
    background: #56CCF2;
    background: linear-gradient(to bottom, #2F80ED, #56CCF2);
    color: #fafafa;
}

.tag-brown {
    background: #D1913C;
    background: linear-gradient(to bottom, #FFD194, #D1913C);
    color: #fafafa;
}

.tag-red {
    background: #cb2d3e;
    background: linear-gradient(to bottom, #ef473a, #cb2d3e);
    color: #fafafa;
}

.card__body h4 {
    font-size: 1.5rem;
    text-transform: capitalize;
}

.card__footer {
    display: flex;
    padding: 2rem 1rem;
    margin-top: auto;
}

.user {
    display: flex;
    gap: .5rem;
}

.user__image {
    border-radius: 50%;
    width: 50px;
    height: 50px;
}

.user__info>small {
    color: #666;
}
Enter fullscreen mode Exit fullscreen mode
/* blogPost.css */

@import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap');

.blog-post-container {
    font-family: 'Roboto', sans-serif;
    padding-top: 2em;
}

hr.blog-hr {
    width: 200px;
    height: 20px;
    margin: 60px auto;
    background: radial-gradient(circle closest-side, #d4d4d4 98%, #0000) 0/calc(100%/5) 100%;
}

.post-heading {
    margin-bottom: 1em;
}

.post-header {
    display: flex;
    flex-direction: row;
    margin-bottom: 3em;
}

.post-header-author {
    display: flex;
    flex-direction: column;
    background-color: rgb(81, 81, 81);
    padding: 2em;
}

.author-avatar {
    display: flex;
}

.author-avatar img {
    width: 75px;
    height: 75px;
    border-radius: 50%;
    border: 2px solid #cddc39;
    filter: drop-shadow(0 0 8px rgb(255, 87, 34));
    margin: auto;
}

.post-header-title {
    display: flex;
    flex-direction: column;
    background-color: rgb(51, 51, 51);
    width: 100%;
    color: white;
    padding: 2em;
}

.author-name {
    color: #f19494;;
}

.publish-date {
    color: #afafff;
    font-style: italic;
}

.post-markdown figcaption {
    font-size: 0.8em;
    background-color: rgb(51, 51, 51);
    color: white;
    padding: 0.5em;
    text-align: center;
}

.shadow-box {
    -webkit-box-shadow: -10px 0px 13px -7px #000000, 10px 0px 13px -7px #000000, 35px -23px 2px -16px rgba(0, 0, 0, 0);
    box-shadow: -10px 0px 13px -7px #000000, 10px 0px 13px -7px #000000, 35px -23px 2px -16px rgba(0, 0, 0, 0)
}

.blog-pre {
    margin-bottom: 3em;;
}

.blog-pre > div {
    -webkit-box-shadow: -10px 0px 13px -7px #000000, 10px 0px 13px -7px #000000, 35px -23px 2px -16px rgba(0, 0, 0, 0);
    box-shadow: -10px 0px 13px -7px #000000, 10px 0px 13px -7px #000000, 35px -23px 2px -16px rgba(0, 0, 0, 0)
}

.blog-ul {
    margin-bottom: 3em;
}

.blog-p {
    margin-bottom: 2em;
}

.blog-table {
    -webkit-box-shadow: -10px 0px 13px -7px #000000, 10px 0px 13px -7px #000000, 35px -23px 2px -16px rgba(0, 0, 0, 0);
    box-shadow: -10px 0px 13px -7px #000000, 10px 0px 13px -7px #000000, 35px -23px 2px -16px rgba(0, 0, 0, 0);
    margin-bottom: 3em !important;
}

code.blog-code:not(pre *) {
    color: rgb(227, 227, 227);
    font-size: 0.9em;
    background-color: rgb(110, 110, 110);
    padding: 4px 6px;
    border-radius: 3px;
    word-break: keep-all;
}

.pop-image {
    cursor: pointer;
    transition: 0.3s ease-in-out;
    -webkit-box-shadow: -10px 0px 13px -7px #000000, 10px 0px 13px -7px #000000, 35px -23px 2px -16px rgba(0, 0, 0, 0);
    box-shadow: -10px 0px 13px -7px #000000, 10px 0px 13px -7px #000000, 35px -23px 2px -16px rgba(0, 0, 0, 0);
}

.pop-image:hover {
    transform: scale(1.01);
    opacity: 0.8;
}
Enter fullscreen mode Exit fullscreen mode

Create a folder in the root directory called lib and a file within called contentful.js. This will contain our functions for fetching data from Contentful:

/* contentful.js */

const space = process.env.NEXT_PUBLIC_CONTENTFUL_SPACE_ID ?? null
const accessToken = process.env.NEXT_PUBLIC_CONTENTFUL_ACCESS_TOKEN ?? null

const client = require('contentful').createClient({
    space: space,
    accessToken: accessToken,
})

// Fetch post meta data for all posts
export async function getPostsMeta() {
    const entries = await client.getEntries({
        content_type: 'blogPost',
        select: 'fields.title,fields.publishDate,fields.slug,fields.featuredImage,fields.category,fields.excerpt,fields.author'
    })
    if (entries.items) {
        return entries.items;
    }
    console.error("Could not fetch blog posts!")
}

// Fetch a single post by slug
export async function getPost(slug) {
    const entries = await client.getEntries({
        content_type: 'blogPost',
        'fields.slug': slug
    })
    if (entries.items) {
        return entries.items;
    }
    console.error(`Could not fetch blog post: ${slug}!`)
}
Enter fullscreen mode Exit fullscreen mode

And also a file called formatFunctions.js. This will have a function for formatting ISO dates:

/* formatFunctions.js */

export function formatDate(str) {
    let date = new Date(str);
    const options = { year: 'numeric', month: 'long', day: 'numeric' };
    return date.toLocaleDateString("en-US", options);
}
Enter fullscreen mode Exit fullscreen mode

Now we can create our main layout component. Create a folder in the root directory called components and create the following components:

/* layout.js */

import Head from 'next/head'
import { Navbar, Nav, Container } from "react-bootstrap";
import { useRouter } from 'next/router'
import NavLink from './navLink';

export default function Layout({
    children,
    title,
    description = "My blog site default description",
    image,
    headCustom = ""
}) {
    const router = useRouter()
    const canonicalUrl = process.env.NEXT_PUBLIC_BASE_URL + router.asPath;

    // Configure dynamic title
    let dispTitle = "My Blog"; // title prefix
    if (title) dispTitle = dispTitle + " | " + title;

    return (
        <>
            <Head>
                <title>{dispTitle}</title>
                <meta name="description" content={description} />
                <meta property="og:url" content={canonicalUrl} />
                <meta property="og:type" content="article" />
                <meta property="og:title" content={dispTitle} />
                <meta property="og:description" content={description} />
                {image &&
                    <meta property="og:image" content={image} />
                }
                {headCustom}
            </Head>
            <Navbar bg="dark" variant="dark">
                <Container>
                    <Navbar.Brand href="#" onClick={() => router.push('/')}>
                        My Blog
                    </Navbar.Brand>
                    <Nav className="me-auto">
                        <NavLink href="/blog">My Blog</NavLink>
                    </Nav>
                </Container>
            </Navbar>
            {children}
        </>
    );
}
Enter fullscreen mode Exit fullscreen mode
// navLink.js

import Link from "next/link";
import { Nav } from "react-bootstrap";

export default function NavLink(props) {
    return (
        <Link href={props.href} passHref>
            <Nav.Link>{props.children}</Nav.Link>
        </Link>
    );
}
Enter fullscreen mode Exit fullscreen mode
/* postCard.js */

import Image from "next/image"

export default function PostCard({
    title,
    image,
    excerpt,
    authorName,
    authorAvatar,
    publishDate,
    category,
    onClick
}) {
    const featuredImage = image
        ? <Image src={image} alt="card__image" className="card__image" width={600} height={338} />
        : <></>

    return (
        <div className="post-card" onClick={onClick}>
            <div className="card__header">
                {featuredImage}
            </div>
            <div className="card__body">
                <span className="tag tag-blue">{category}</span>
                <h4>{title}</h4>
                <p>{excerpt}</p>
            </div>
            <div className="card__footer">
                <div className="user">
                    <img src={authorAvatar} alt={authorName} className="user__image" />
                    <div className="user__info">
                        <h5>{authorName}</h5>
                        <small>{publishDate}</small>
                    </div>
                </div>
            </div>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode
/* post.js */

import { Container } from "react-bootstrap"
import Image from "next/image"
import PostBody from "./postBody"
import { formatDate } from "../lib/formatFunctions";

export default function Post({
    date,
    slug,
    image = "/kjd",
    title,
    category,
    content,
    author
}) {
    const authorAvatar = 'https:' + author.picture.fields.file.url;
    const featuredImage = image ? <Image src={image} alt="" width={1900} height={1080} /> : <></>;

    return (
        <Container key={slug} className="blog-post-container">
            <div className="post-header shadow-box">
                <div className="post-header-author">
                    <div className="author-avatar">
                        <img height={75} width={75} src={authorAvatar} alt={author.name} />
                    </div>
                </div>
                <div className="post-header-title">
                    <div>
                        <h1>{title}</h1>
                        <div className="by-line">Published by {" "}
                            <span className="author-name">{author.name}</span> on {" "}
                            <span className="publish-date">{formatDate(date)}</span>
                        </div>
                    </div>
                </div>
            </div>
            {featuredImage}
            <PostBody content={content} />
        </Container>
    )
}
Enter fullscreen mode Exit fullscreen mode
/* postBody.js */

import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { a11yDark } from 'react-syntax-highlighter/dist/cjs/styles/prism'
import PopImage from './popImage';

export default function PostBody({ content }) {
    const HeaderOne = ({ children }) => <h1 className="post-heading">{children}</h1>
    const HeaderTwo = ({ children }) => <h2 className="post-heading">{children}</h2>
    const HeaderThree = ({ children }) => <h3 className="post-heading">{children}</h3>
    const HeaderFour = ({ children }) => <h4 className="post-heading">{children}</h4>
    const Table = ({ children }) => <table className="table table-striped table-bordered table-responsive-sm blog-table shadow-box">{children}</table>
    const Thead = ({ children }) => <thead className="thead-dark">{children}</thead>
    const Pre = ({ children }) => <pre className="blog-pre">{children}</pre>
    const Ul = ({ children }) => <ul className="blog-ul">{children}</ul>
    const P = ({ children }) => <p className="blog-p">{children}</p>
    const Hr = () => <hr className="blog-hr" />

    return (
        <ReactMarkdown
            className='post-markdown'
            linkTarget='_blank'
            rehypePlugins={[rehypeRaw]}
            remarkPlugins={[remarkGfm]}
            components={{
                h1: HeaderOne,
                h2: HeaderTwo,
                h3: HeaderThree,
                h4: HeaderFour,
                table: Table,
                thead: Thead,
                pre: Pre,
                ul: Ul,
                p: P,
                hr: Hr,
                code({ node, inline, className = "blog-code", children, ...props }) {
                    const match = /language-(\w+)/.exec(className || '')
                    return !inline && match ? (
                        <SyntaxHighlighter
                            style={a11yDark}
                            language={match[1]}
                            PreTag="div"
                            {...props}
                        >
                            {String(children).replace(/\n$/, '')}
                        </SyntaxHighlighter>
                    ) : (
                        <code className={className} {...props}>
                            {children}
                        </code>
                    )
                },
                img: ({ src, ...props }) => <PopImage src={src} {...props} />
            }}
        >
            {content}
        </ReactMarkdown>
    )
}
Enter fullscreen mode Exit fullscreen mode
/* popImage.js */

import React from "react";
import Swal from "sweetalert2";
import Image from "next/image";

export default class PopImage extends React.Component {
    state = {
        isOpen: false,
        winWidth: null
    };

    constructor(props) {
        super(props);

        // Fix contentful's image prefix
        if (this.props.src.match(/^\/\//)) {
            this.src = 'https:' + this.props.src;
        }else{
            this.src = this.props.src;
        }

        this.dialogTitle = (this.props.alt) ? this.props.alt : "Image";
    }

    componentDidMount() {
        this.setState({
            winWidth: window.innerWidth
        });
    }

    imgPop = () => {
        this.setState({ isOpen: !this.state.isOpen });

        Swal.fire({
            title: this.props.title,
            html: `
                <div style="display: flex; width:100%;">
                    <img src="${this.src}" alt="${this.props.alt}" className="${this.props.className}"
                        onClick="document.querySelector('.swal2-confirm').click()" style="margin: auto; width:100%;" />
                </div>
            `,
            confirmButtonText: "CLOSE",
            width: '100%',
            backdrop: 'black',
            background: 'black',
            padding: '1px',
            margin: '1px',
            loaderHtml: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="margin: auto; background: none; display: block; shape-rendering: auto;" width="200px" height="200px" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
            <g transform="translate(26.666666666666668,26.666666666666668)">
              <rect x="-20" y="-20" width="40" height="40" fill="#93dbe9">
                <animateTransform attributeName="transform" type="scale" repeatCount="indefinite" dur="1s" keyTimes="0;1" values="1.1500000000000001;1" begin="-0.3s"></animateTransform>
              </rect>
            </g>
            <g transform="translate(73.33333333333333,26.666666666666668)">
              <rect x="-20" y="-20" width="40" height="40" fill="#689cc5">
                <animateTransform attributeName="transform" type="scale" repeatCount="indefinite" dur="1s" keyTimes="0;1" values="1.1500000000000001;1" begin="-0.2s"></animateTransform>
              </rect>
            </g>
            <g transform="translate(26.666666666666668,73.33333333333333)">
              <rect x="-20" y="-20" width="40" height="40" fill="#5e6fa3">
                <animateTransform attributeName="transform" type="scale" repeatCount="indefinite" dur="1s" keyTimes="0;1" values="1.1500000000000001;1" begin="0s"></animateTransform>
              </rect>
            </g>
            <g transform="translate(73.33333333333333,73.33333333333333)">
              <rect x="-20" y="-20" width="40" height="40" fill="#3b4368">
                <animateTransform attributeName="transform" type="scale" repeatCount="indefinite" dur="1s" keyTimes="0;1" values="1.1500000000000001;1" begin="-0.1s"></animateTransform>
              </rect>
            </g>
            <!-- [ldio] generated by https://loading.io/ --></svg>`
        });
    };

    render() {
        return (
            <Image
                {...this.props}
                className="pop-image"
                src={this.src}
                onClick={this.imgPop}
                alt={this.props.alt}
                width={1920}
                height={1080}
            />
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Next, create a folder in /pages/ called blog, and a file within called index.js. This will be our main blog index:

/* index.js */

import Layout from "../../components/layout"
import { Container } from "react-bootstrap"
import { getPostsMeta } from "../../lib/contentful";
import PostCard from "../../components/postCard";
import { formatDate } from "../../lib/formatFunctions";
import { useRouter } from "next/router";

export default function Blog({ posts }) {
    const router = useRouter();

    const handePostClick = (slug) => {
        router.push("/blog/post/"+slug);
    }

    const postList = posts.map((post) => {
        const featuredImage = post.fields.featuredImage ? 'https:' + post.fields.featuredImage.fields.file.url : null;
        console.log(post)
        return (
            <PostCard
                key={post.fields.slug}
                title={post.fields.title}
                image={featuredImage}
                excerpt={post.fields.excerpt}
                authorAvatar={post.fields.author.fields.picture.fields.file.url}
                authorName={post.fields.author.fields.name}
                publishDate={formatDate(post.fields.publishDate)}
                category={post.fields.category.fields.categoryName}
                onClick={() => { handePostClick(post.fields.slug) }}
            />
        )
    })

    return (
        <Layout title="blog">
            <div className="post-list-wrapper">
                <div className="post-list-container">
                    {postList}
                </div>
            </div>
        </Layout>
    )
}

export async function getStaticProps() {
    const posts = await getPostsMeta();

    return {
        props: {
            posts: posts
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Next, create a folder in /pages/blog called /post and a file within called [slug].js. This is a special Next.JS file that allows dynamic routing based on the slug in the URI path:

/* [slug].js */

import { useRouter } from 'next/router'
import Layout from '../../../components/layout';
import Post from "../../../components/post";
import ErrorPage from 'next/error'
import { getPostsMeta, getPost } from '../../../lib/contentful';

function PostDetails({ post }) {
    console.log(post)
    const router = useRouter()

    if (!router.isFallback && typeof post === typeof undefined || !post.hasOwnProperty('slug')) {
        return <ErrorPage statusCode={404} />
    }

    const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
    const canonicalUrl = baseUrl + router.asPath;

    const featuredImage = post.featuredImage ? 'https:' + post.featuredImage.fields.file.url : null;

    // LdJSON data for Google Rich Results
    const ldJsonBlog = {
        "@context": "https://schema.org",
        "@type": "BlogPosting",
        "headline": post.title,
        "about": post.excerpt,
        "image": [featuredImage],
        "datePublished": post.publishDate,
        "dateModified": post.publishDate,
        "author": {
            "@type": "Person",
            "name": post.author.fields.name,
            "image": 'http:' + post.author.fields.picture.fields.file.url,
            "url": canonicalUrl
        }
    }

    return (
        <Layout
            title={post.title}
            description={post.excerpt}
            headCustom={
                <>
                    <script type='application/ld+json' dangerouslySetInnerHTML={{ __html: JSON.stringify(ldJsonBlog) }} />
                </>
            }
        >
            <Post
                date={post.publishDate}
                image={featuredImage}
                title={post.title}
                excerpt={post.excerpt}
                content={post.content}
                slug={post.slug}
                category={post.category.fields}
                author={post.author.fields}
            />
        </Layout>
    );
}

export async function getStaticProps({ params }) {
    const res = await getPost(params.slug)
    const post = res.length && res[0].hasOwnProperty('fields') ? res[0].fields : {};
    return {
        props: {
            post: post
        },
    }
}

export async function getStaticPaths() {
    const meta = await getPostsMeta();

    const fs = require('fs');
    const path = require('path');
    const dataFile = path.join(__dirname, '../../../../../public/blogPostMeta.json');

    // Write meta-data to JSON file
    const postsMeta = [];
    meta.forEach(function (item) {
        const fields = item.fields;
        const id = item.sys.id;

        if (id) {
            postsMeta.push(fields);
        }
        fs.writeFileSync(dataFile, JSON.stringify(postsMeta, null, 4));
    });

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

    return {
        paths,
        fallback: false
    };
}

export default PostDetails;
Enter fullscreen mode Exit fullscreen mode

Now restart the dev server with npm run dev and navigate to http://localhost:3000/blog. You should see your test post displayed as a card. Click on the card and next/router should navigate you to the dynamic [slug].js page. Notice the progress bar and single-page app loading, which is the doing of next/router.

Step 5: Publish Your App on Vercel

Switch over to GitHub Desktop and commit your changes and push to origin:

Commit Changes to GitHub

Ok, it's time to go live! Head over to Vercel and create your free account. You can use GitHub to register, which is a good idea because you're going to link your GitHub account to Vercel for automatic building of your app.

Once you have your account and GitHub linked, create a new project and import your blog app:

Create a New Vercel Project

Next, we need to add our environment variables to be able to connect to Contentful. Don't worry about our BASE_URL variable, we need to wait to get our assigned Vercel domain to add that:

Add Your Environment Variables

Finally, click Deploy. Vercel will pull your commit from GitHub and build your app with statically-generated blog pages! When the build completes, you should get some confetti:

Vercel Build Completed

Now you need to set a custom domain for your app. If you have a domain name you want to use, you can add it by adding some records on your registrar, but for the purposes of this tutorial, we're going to use a vercel.app subdomain. Click on Dashboard and then click View Domains. On the domain name that was assigned, click Edit and enter your custom domain name.

Customize Your Domain

The last thing you need to do is go back to your project settings and add the NEXT_PUBLIC_BASE_URL environment variable to be your new domain name. Then re-deploy your app.


I hope you found this tutorial useful. Designly's blog uses much of this code and we're always developing new ways to do things. For more great tutorials, please visit Our Blog.

Top comments (0)