DEV Community

Cover image for How to Build a Developer Blog with Storyblok and Nextjs.
Frank Otabil Amissah
Frank Otabil Amissah

Posted on • Updated on

How to Build a Developer Blog with Storyblok and Nextjs.

In this tutorial, I walk you through concise steps to build a developer blog with syntax highlighting using Storyblok as the backend.

Here's a preview of what you'll build.

What's Storyblok?

Storyblok is a headless Content Management System.

You should have a basic knowledge of how to build React and Node applications to follow through this tutorial.

You can install Node's latest version from their official website if you don't already have it installed.

Without further ado let's dive in.

Setting up Storyblok as backend.

Create a new Storyblok account if you don't already have one and create a space for your project.

In Storyblok, your projects are stored in spaces.

Creating Blogpost content folder.

Storyblok uses folders to group contents of similar structure in a space.

Follow these steps to make a folder:

  1. In your content tab, click "+ Create new" and select Folder.

  2. Input Blogpost for the folder name and select "Add new" under content-type.

  3. Input Blogpost as the content-type and check both options under "Folder content settings". Click "Create" when you're done.

You should have a similar setup as the image below.

Blogpost content type folder setup

Creating Author contents folder.

Follow the steps used in making the blogpost folder to create one for authors. You should have a similar setup as the image below.

Author content-type folder setup

Creating Blogpost schema.

Schema represents the field structure for data entry.

Follow the steps below to add fields for an excerpt, featured image, article body, and author in Blogpost:

  1. Open the Blogpost folder, click "+ Create new" and select Story.

  2. Enter your article title in the Name input field and click "Create".

Creating a blogpost story

To add the fields click "Define" to open the blocks tab on the right of your screen.

Adding fields to schema

Adding Excerpt field.

Now, follow these steps to add an excerpt field:

  1. Input "Excerpt" as the Name for the field.

  2. Click on the icon on the left of the Name input field.

  3. Select "Textarea" and click "Add" to create an excerpt field.

Adding excerpt field.

Adding Featured Image field.

Follow these steps to add Featured image field:

  1. Enter "Featured_Image" in the Name input field.

  2. Click the icon on the right, select "Asset" and click "Add" to create a featured image field.

  3. Click on the Featured_image field to open the Edit field tab and set the filetype to "Images" as shown in the image below.

Now, click "Save & Back to fields" to save the settings.

Featured image edit field tab

Adding Article body field.

Let's add Article body for the main part of the content.

  1. Enter "Article_Body" in the Name field.

  2. Click on the icon and select "Markdown" as the type.

  3. Click "Add" to add the field.

Adding Author field.

Now let's add your last field.

  1. Enter "Author" in the Name field.

  2. Click the icon, select "Author" and click Add to add the author field.

  3. Click on the Author field to open the Edit field tab. Set Source to "stories" and restrict to content-type author as shown below.

The field creates a relationship between Blog posts and Authors, such that each post has an author assigned, and one author can have many different posts.

Author edit field tab

Creating Author schema.

For the author folder let's add two fields, one for profile pic and the other for bio.

Adding Profile Photo field.

Navigate into the author folder and follow these steps to add Profile photo field.

  1. Enter "Profile_image" in the Name field, select "Asset" and click "Add".

  2. Click on the Profile_image field to open the Edit field tab, there set the filetype to "Images" and click "Save & Back to fields".

Adding Author Bio field.

Input "Bio" as the Name for the field, select "Textarea" and click "Add" to create a Bio field.

Note: The title of the story entry will be the Author name of the Author so no need to add extra fields for that.

That's it! This should be okay for the tutorial.

I recommend you to add some content to your space before continuing with the rest of this tutorial.

Building the front end.

To start a new Next.js application, run this command in your terminal:

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

On installation choose the options as shown below in the light-blue color.

Nextjs installation options

Note: This tutorial uses the pages directory.

After a successful installation, run the following command to navigate into your project's directory.

cd <project name>
Enter fullscreen mode Exit fullscreen mode

Now, run the following command to install the application dependencies:

npm i next-mdx-remote graphql-request graphql rehype-prism-plus rehype-code-titles
Enter fullscreen mode Exit fullscreen mode

The command above installs next-mdx-remote to serialize markdown returned from Storyblok, graphql and graphql-request to make graphql requests to Storyblok's graphql API, and rehype plugins for syntax highlighting codes in your articles.

Now, run the following command to start a development server on localhost:3000:

npm run dev
Enter fullscreen mode Exit fullscreen mode

If everything was successful, you should have something like this in your browser.

Default Nextjs index page

Structuring directory.

Now, let's get ready to create our UI.

Open your text editor and structure your /src directory to look like so.

|--/src
|  |--/Components
|  |  |--Card.js
|  |--/pages
|  |  |--/api
|  |  |--_app.js
|  |  |--_document.js
|  |  |--index.js
|  |  |--/blog
|  |  |  |--[slug].js
|  |--/styles
|  |  |--global.css
|  |  |--Home.module.css
|  |  |--Slug.module.css
|  |  |--prismTheme.css
Enter fullscreen mode Exit fullscreen mode

The "Component" directory is where your UI components will live.

Note: The pages and styles directories are defaults to Next.js applications.

Building UI components and pages.

In your /Component/Card.js file and add the following code:

import Image from "next/image";
import style from "@/styles/Card.module.css";
import Link from "next/link";

function PostCard({ title, content, slug }) {

//post card for archive page

  return (
    <div className={style.postContainer}>
      <div>
        <div className={style.postImage}>
          <Image
            src={content.Featured_Image.filename}
            alt={content.Featured_Image.alt}
            fill
            className={style.imgs}
          />
        </div>
        <Link href={`blog/${slug}`}>
          <h2 className={style.postTitle}>{title}</h2>
        </Link>
      </div>

      {/* post author info */}
      <div className={style.postAuthorInfo}>
        <div className={style.avatar}>
          <Image
            src={content.Author.content.Cover_Photo.filename}
            alt="article image"
            fill
            className={style.avatarRad}
          />
        </div>
        <div>
          <div className={style.postInfo}>
          <h3>{content.Author.name}</h3>

            <p>April 26,2023</p>
          </div>
        </div>
      </div>
      {/* end of post author info */}
    </div>
  );
}

export default PostCard;
Enter fullscreen mode Exit fullscreen mode

The code above creates the postcards for your archive posts as shown below.

Post cards from devblok

Now, let's build a single post page UI for posts.

Override the content inside /pages/index.js with the following code:

import Head from "next/head";
import { gql, GraphQLClient } from "graphql-request";
import Image from "next/image";
import styles from "@/styles/Home.module.css";
import PostCard from "@/Components/Card";
import Link from "next/link";

export async function getServerSideProps() {

  const url = "https://gapi.storyblok.com/v1/api";
//instantiates a client to make request
  const client = new GraphQLClient(url, {
    headers: {
      token: process.env.PublicToken,
      version: "published",
    },
  });

//query strings for retrieving all blog posts
  const query = gql`
    {
      BlogpostItems {
        items {
          id
          name
          slug
          content {
            Featured_Image {
              filename
              alt
            }
            Author {
              name
              content
            }
          }
        }
      }
    }
  `;

// fetch multiple post from Storyblok
  const { BlogpostItems } = await client.request(query);
  return { props: { data: BlogpostItems.items } };
}

export default function Home({ data }) {
  return (
    <>
      <Head>
        <title>Create Next App</title>
        <meta name="description" content="Generated by create next app" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <main className={styles.main}>
        <div className={styles.itemContainer}>
          {data.map(({ id, name, slug, content }) => {
            return (
              <div key={id}>
                <PostCard title={name} content={content} slug={slug} id={id}/>
              </div>
            );
          })}
        </div>
      </main>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

The code above fetches data from Storyblok using graphql and renders them on your archive page.

Now, create a pages/blog/[slug].js file and add the following code:

import { gql, GraphQLClient } from "graphql-request";
import { MDXRemote } from "next-mdx-remote";
import {serialize} from "next-mdx-remote/serialize";
import rehypePrism from 'rehype-prism-plus';
import rehypeCodeTitles from 'rehype-code-titles';
import style from "@/styles/Slug.module.css";
import Image from "next/image";

export async function getStaticPaths() {
  const url = "https://gapi.storyblok.com/v1/api";

//instantiates a new client
  const client = new GraphQLClient(url, {
    headers: {
      token: process.env.PublicToken,
      version: "published",
    }
  });

//query string to query  all slugs for pre-rendering
  const querySlug = gql`
    {
      BlogpostItems {
        items {
          slug
        }
      }
    }
  `;

// fetch for post slugs from Storyblok
  const { BlogpostItems } = await client.request(querySlug);
  const paths = BlogpostItems.items.map(({slug}) =>  ({ params: {slug} }));
  return { paths , fallback: "blocking" };
}

export async function getStaticProps({params}) {
  const url = "https://gapi.storyblok.com/v1/api";

  const client = new GraphQLClient(url, {
    headers: {
      token: process.env.PublicToken,
      version: "published",
    },
  });

//query string to query just one post content based on slug in url
  const query = gql`
  query mypost($id: ID!){
    BlogpostItem(id: $id){
      name
      content {
        Date
        Author{
          name
        }
        Article_Body
        Featured_Image {
          filename
          alt
        }
      }
    }
  }`;

// fetching data from Storyblok
  const { BlogpostItem } = await client.request(query, {id: `posts/${params.slug}`});

  const {name, content} = BlogpostItem;

// serialize content with next-mdx-remote.
  const source = await serialize(BlogpostItem.content.Article_Body, { mdxOptions: {
    rehypePlugins: [rehypeCodeTitles, rehypePrism]
  }})

  return { props: { Source: source, name, content } };
}

function SinglePost({ Source , name, content }) {
  return (

  <div className={style.wrapper}>

    <div className={style.imgWrapper}>
      <Image src={content.Featured_Image.filename} alt={content.Featured_Image.alt} fill />
  </div>
  <div><h1>{name}</h1></div>
  <div className={style.postContent}>

    <div>
      <MDXRemote {...Source}/>
    </div>
  </div>
  </div>);
}

export default SinglePost;
Enter fullscreen mode Exit fullscreen mode

The code above check for post slugs matching the URL, fetch the data for that slug and renders them as a single post content on request.

Add the following code to the top of your _app.js file to apply the syntax highlighting theme.

import '@/styles/prismTheme.css'
Enter fullscreen mode Exit fullscreen mode

Adding support for remote images.

By defaults, Nextjs prevents the use of external images in your application.

To allow the use of images from an external source you must add the URL of that source to the next.config file.

In the root of your project directory, override next.config.js with the following:

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '**.storyblok.com',
      },
    ],
  },
}

module.exports = nextConfig

Enter fullscreen mode Exit fullscreen mode

That's it for our blog pages.

Add the following code to env.local in your root directory to authorize your requests.

PublicToken = "public token"
Enter fullscreen mode Exit fullscreen mode

You can get your token from your space in Settings>Access Tokens.

Storyblok settings tab

Styling UI components.

In the styles folder, let's add CSS codes to style our card component and UI pages.

Override the global.css file with the code below:

:root {
  --main-bgColor: #232323;
  --black: #000;
  --white: #fff;
  --gray: #9A9494;
  --light-gray: #eaeaea;
}

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

html,
body {
  max-width: 100vw;
  background-color: var(--main-bgColor);
  font-size: 1.1rem;
  overflow-x: hidden;
}

a {
  text-decoration: none;
  color: #000;
}
Enter fullscreen mode Exit fullscreen mode

Now, create a Card.module.css and add the following code:

.postContainer {
  box-sizing: border-box;
  padding: 20px 20px;
  width: 320px;
  height: 450px;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  background: var(--light-gray);
  border-radius: 12px;
}

.postTitle:hover {
opacity: 0.7;
}

.postImage {
  margin: auto;
  width: 280px;
  height: 198px;
  position: relative;
}
.postTitle {
  margin-top: 25px; 
 font-size: 1.2 rem;
  font-weight: 600;

}

.postTitle:hover {
opacity: 0.6;
}

.postAuthorInfo {
  display: flex;
  flex-direction: row;
  gap: 15px;
}
.postInfo {
  display: flex;
  flex-direction:column;
  gap: 7px;
}
.imgs {
  border-radius: 8px;
}
.avatar {
  width: 57px;
  height: 57px;
  position: relative;
  border-radius: 50%;
}
.avatarRad {
    border: 5px solid gray;
  border-radius: 100%;
}
Enter fullscreen mode Exit fullscreen mode

The code above applies the styling to our Card.js components.

Now, override Home.module.css with the following code:

.main {
  min-height: 100vh;
}

.itemContainer {
  max-width: 1020px;
  margin: 60px auto;
  display: flex;
  flex-direction: row;
  gap: 30px;
  flex-wrap: wrap;
}

/* Mobile */
@media (max-width: 700px) {
  .itemContainer {
    max-width: 320px;
  }
}

/* Tablet and Smaller Desktop */
@media (min-width: 701px) and (max-width: 1120px) {
  .itemContainer {
    max-width: 674px;
    }
}
Enter fullscreen mode Exit fullscreen mode

This code styles your index.js page.

Create a Slug.module.css file and add the following code:

.wrapper{
    background-color: whitesmoke;
    width: 800px;
    min-height: 700px;
    padding: 70px 70px;
    margin: 60px auto;
    border-radius: 10px;
    display: flex;
    flex-direction: column;
    gap: 30px;
}

.imgWrapper {
    width: 100%;
    height: 400px;
    position: relative;
}

/* Mobile */
@media (max-width: 700px) {
    .wrapper {
      max-width: 320px;
      padding: 20px 20px;
    }
    .imgWrapper {
        width: 100%;
        height: 200px;
    }
  }

  /* Tablet and Smaller Desktop */
  @media (min-width: 701px) and (max-width: 1120px) {
    .wrapper {
      max-width: 674px;
      }
  }
Enter fullscreen mode Exit fullscreen mode

This code styles our [slug].js pages.

Add the following code to prismTheme.css to apply the syntax highlight theme.

code[class*="language-"],
pre[class*="language-"] {
    -moz-tab-size: 2;
    -o-tab-size: 2;
    tab-size: 2;
    -webkit-hyphens: none;
    -moz-hyphens: none;
    -ms-hyphens: none;
    hyphens: none;
    white-space: pre;
    white-space: pre-wrap;
    word-wrap: normal;
    font-family: Menlo, Monaco, "Courier New", monospace;
    font-size: 14px;
    color: #76d9e6;
    text-shadow: none;
}

pre > code[class*="language-"] {
    font-size: 1em;
}

pre[class*="language-"],
:not(pre) > code[class*="language-"] {
    background: #2a2a2a;
}

pre[class*="language-"] {
    padding: 15px;
    border-radius: 4px;
    border: 1px solid #e1e1e8;
    overflow: auto;
    position: relative;
}

pre[class*="language-"] code {
    white-space: pre;
    display: block;
}

:not(pre) > code[class*="language-"] {
    padding: 0.15em 0.2em 0.05em;
    border-radius: .3em;
    border: 0.13em solid #7a6652;
    box-shadow: 1px 1px 0.3em -0.1em #000 inset;
}

.token.namespace {
    opacity: .7;
}

.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
    color: #6f705e;
}

.token.operator,
.token.boolean,
.token.number {
    color: #a77afe;
}

.token.attr-name,
.token.string {
    color: #e6d06c;
}

.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
    color: #e6d06c;
}

.token.selector,
.token.inserted {
    color: #a6e22d;
}

.token.atrule,
.token.attr-value,
.token.keyword,
.token.important,
.token.deleted {
    color: #ef3b7d;
}

.token.regex,
.token.statement {
    color: #76d9e6;
}

.token.placeholder,
.token.variable {
    color: #fff;
}

.token.important,
.token.statement,
.token.bold {
    font-weight: bold;
}

.token.punctuation {
    color: #bebec5;
}

.token.entity {
    cursor: help;
}

.token.italic {
    font-style: italic;
}

code.language-markup {
    color: #f9f9f9;
}

code.language-markup .token.tag {
    color: #ef3b7d;
}

code.language-markup .token.attr-name {
    color: #a6e22d;
}

code.language-markup .token.attr-value {
    color: #e6d06c;
}

code.language-markup .token.style,
code.language-markup .token.script {
    color: #76d9e6;
}

code.language-markup .token.script .token.keyword {
    color: #76d9e6;
}

/* Line highlight plugin */
.line-highlight.line-highlight {
    padding: 0;
    background: rgba(255, 255, 255, 0.08);
}

.line-highlight.line-highlight:before,
.line-highlight.line-highlight[data-end]:after {
    padding: 0.2em 0.5em;
    background-color: rgba(255, 255, 255, 0.4);
    color: black;
    height: 1em;
    line-height: 1em;
    box-shadow: 0 1px 1px rgba(255, 255, 255, 0.7);
}
Enter fullscreen mode Exit fullscreen mode

Feel free to use any other theme from prism themes.

That's it for our styling! Reload your application to view changes.

Conclusion

You've successfully built a developer blog that supports syntax highlighting with Storyblok and Next.js. I encourage you to deploy your application to any hosting platform and share it with friends.

Thanks for reading this article! If you like more of these articles please follow me. You can also find the source code on this GitHub repo.

Top comments (0)