DEV Community

Donna Brown
Donna Brown

Posted on • Edited on

How to Build a Developer Blog using Next JS 13 and Contentlayer - Part one

Introduction

This tutorial will show how to create a customizable blog for developers to write about technical topics and provide coding solutions. This blog will have these features:

Code highlighting and formatting with a copy button
Dark and light modes
Responsive
Display of github projects
Add tags and categories to posts
Search for specific topics in blog
Sitemap for SEO

Github repository: https://github.com/donnabrown77/developer-blog

Prerequisites
JavaScript, React, Vscode. Knowledge of TypeScript, Next JS, Tailwind CSS is helpful.

Installation

npx create-next-app@latest --ts

Install the following npm packages:

npm install @next/mdx contentlayer date-fns encoding graphql graphql-request next-contentlayer next-themes rehype-pretty-code remark-gfm shiki shiki-themes unist-utils-visit

Package descriptions:

@next/mdx - Sources data from local files, allowing you to create pages with an .mdx extension, directly in your /pages or /app directory.

contentlayer - A content SDK that validates and transforms your content into type-safe JSON data you can easily import into your application’s pages.

date-fns - Toolset for manipulating JavaScript dates in a browser and Node.js.

encoding - Is a simple wrapper around iconv-lite to convert strings from one encoding to another.

graphql - JavaScript reference implementation for GraphQL, a query language for apis.

graphql-request - Minimal GraphQL client supporting Node and browsers for scripts or simple apps

next-contentlayer - Plugin to tightly integrate Contentlayer into a Next.js

next-themes - An abstraction for themes in your Next.js app.

rehype-pretty-code - plugin powered by the Shiki syntax highlighter that provides beautiful code blocks for Markdown or MDX.

remark-gfm - plugin to support GFM (autolink literals, footnotes, strikethrough, tables, tasklists)

shiki - syntax highlighter

shiki-themes - themes for shiki

unist-utils-visit - utility to visit nodes in a tree

Create sample blog posts

The blog post is a text file using a .mdx extension. MDX allows you to use JSX in your markdown content. You can import components, such as interactive charts or alerts, and embed them within your content. Each .mdx file uses frontmatter. Frontmatter is placed at the top of the MDX file between sets of three hyphens (---). Each frontmatter field should be placed on its own line.

Here is a definition of frontmatter: https://kabartolo.github.io/chicago-docs-demo/docs/mdx-guide/writing/#frontmatter

Make a new directory called _posts in your root directory. I used the underscore as the first character for the directory name to make it a private folder in Next JS. Private folders are not considered by Next JS’s routing system. Here is a link to a explanation of private folders: https://nextjs.org/docs/app/building-your-application/routing/colocation#private-folders.
This is where the mdx files are located. I created five frontmatter categories: title, date, tags, and author. Tags describe the topic or topics for a blog post. A tag is used for searching a specific blog topic. Here are links to sample mdx files:

https://github.com/donnabrown77/developer-blog/tree/main/_posts

You can change these to whatever you need.

next.config.js, tsconfig.json, contentlayerconfig.ts

Create a file called next.config.js in your root directory.

/** @type {import('next').NextConfig} */
const nextConfig = {};


const { withContentlayer } = require("next-content layer");


module.exports = withContentlayer({
experimental: {
appDir: true,
// https://stackoverflow.com/questions/75571651/using-remark-and-rehype-plugins-with-nextjs-13/75571708#75571708
mdxRs: false,
},
});
Enter fullscreen mode Exit fullscreen mode

Modify tsconfig.json to look like this:

"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"@/components/*": ["components/*"],
"@/data/*": ["_data/*"],
"contentlayer/generated": ["./.contentlayer/generated"],
}
Enter fullscreen mode Exit fullscreen mode

Create the file contentlayer.config.ts in the root directory.
Contentlayer is a content SDK that validates and transforms your content into type-safe JSON data you can easily import into your application.

Add this code to that file:

import { defineDocumentType, makeSource } from "contentlayer/source-files";

const Post = defineDocumentType(() => ({
  name: "Post",
  filePathPattern: `**/*.mdx`,
  contentType: "mdx",
  fields: {
    title: {
      type: "string",
      description: "The title of the post",
      required: true,
    },
    date: {
      type: "date",
      description: "The date of the post",
      required: true,
    },
    tags: {
      type: "json",
      description: "post topics",
      required: true,
    },
    excerpt: {
      type: "string",
      description: "summary of post",
      required: true,
    },
    author: {
      type: "string",
      description: "who wrote the post",
      required: false,
    },
  },
  computedFields: {
    url: {
      type: "string",
      resolve: (doc) => `/posts/${doc._raw.flattenedPath}`,
    },
  },
}));

export default makeSource({
  contentDirPath: "_posts",
  documentTypes: [Post],
  mdx: {},
});

Enter fullscreen mode Exit fullscreen mode

Create a components directory.

In the components directory, create two files MagnifyingGlass.tsx and Postcard.tsx. MagnifyingGlass.tsx is the svg to draw a maginifying glass used for an input control. PostCard.tsx returns the jsx for a single post.

app/components/MagnifyingGlass.tsx

import React from "react";
/**
 *
 * @returns svg for search input control
 */
const MagnifyingGlass = () => {
  return (
    <svg
      className='absolute right-3 top-3 h-5 w-5 text-gray-400 dark:text-gray-300'
      xmlns='http://www.w3.org/2000/svg'
      fill='none'
      viewBox='0 0 24 24'
      stroke='currentColor'
    >
      <path d='M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'></path>
    </svg>
  );
};

export default MagnifyingGlass;

Enter fullscreen mode Exit fullscreen mode

app/components/PostCard.tsx

import React from "react";
import { Post } from "contentlayer/generated";
import Link from "next/link";
import { format, parseISO } from "date-fns";

/**
 *
 * @param post
 * @returns jsx to display post
 */
const PostCard = (post: Post) => {
  let tags = [...post.tags];
  return (
    <article className='mb-12'>
      <div className='space-y-2 xl:grid xl:grid-cols-4 xl:items-baseline xl:space-y-0'>
        <dl>
          <dd className='text-gray-500 dark:text-gray-400 text-base font-medium leading-6'>
            <time dateTime={post.date} className='mr-2 '>
              {format(parseISO(post.date), "LLLL d, yyyy")}
            </time>
          </dd>
        </dl>
        <span className='text-gray-500 dark:text-gray-400'>{post.author}</span>
        <div className='space-y-5 xl:col-span-3'>
          <div className='space-y-6'>
            <div>
              <h2 className='text-2xl font-bold leading-8 tracking-tight'>
                <Link href={post.url}>{post.title}</Link>
              </h2>
              <div className='flex flex-wrap'>
                {post.tags && (
                  <ul className='inline-flex'>
                    {tags.map((tag) => (
                      <li
                        key={post.date}
                        className='mr-3 uppercase block text-sm text-blue-800 dark:text-blue-400 font-medium'
                      >
                        {tag}
                      </li>
                    ))}
                  </ul>
                )}
              </div>
            </div>

            <div className='text-gray-500 dark:text-gray-400 prose max-w-none'>
              {post.excerpt}
            </div>
          </div>
          <div className='text-base font-medium leading-6'>
            <Link
              href={post.url}
              className=' text-blue-800 dark:text-blue-400 hover:text-blue-400 dark:hover:text-blue-200'
            >
              Read more →
            </Link>
          </div>
        </div>
      </div>
    </article>
  );
};

export default PostCard;

Enter fullscreen mode Exit fullscreen mode

Changes to page.tsx in app directory.

Remove everything from page.tsx and replace it with this code. Note the use of “use client”; at the top of the file which makes it a client component instead of the default server component in Next JS 13. When a user types in characters in the input control, the text is compared to the tags field of the mdx file. If there are posts with any of those tags, the page is rerendered to show only posts containing those tags.

"use client";
import React, { useState } from "react";
import { allPosts } from "contentlayer/generated";
import { compareDesc } from "date-fns";
import PostCard from "@/components/PostCard";
import MagnifyingGlass from "@/components/MagnifyingGlass";
import "./globals.css";

export default function Home() {
  // handleInput will contain an array of topics or nothing
  // if there is something returned, look for a matching post
  // then display matching posts
  const [topic, setTopic] = useState<string>("");
  // get text from input control, use the value to set the topic.
  const handleInput = (event: React.ChangeEvent<HTMLInputElement>) => {
    setTopic(event.currentTarget.value);
  };

  // given an array of tags, return if there is a matching topic
  function findTags(t: string[]) {
    for (const element of t) {
      // Matching topics are not case sensitive
      let str1: string = element.toString().toLowerCase();
      let str2: string = topic.toString().toLowerCase();
      if (str1 === str2) return true;
    }
    return false;
  }
  let posts = allPosts.sort((a, b) =>
    compareDesc(new Date(a.date), new Date(b.date))
  );

  // test array of tags for matching topic
  let filteredPosts = posts.filter((post) => findTags(post.tags));
  // if there are a posts which match the topic, display only those posts
  if (filteredPosts.length > 0) posts = filteredPosts;

  return (
    <div className='mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0'>
      <div className='space-y-2 pt-6 pb-8 md:space-y-5'>
        <h1 className='text-3xl mb-8'>Developer Blog</h1>
        <div className='relative max-w-lg mb-5 dark:bg-transparent'>
          <input
            type='text'
            aria-label='Search articles'
            placeholder='Search articles'
            value={topic}
            onChange={handleInput}
            size={100}
            className='block w-full rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-900 dark:bg-gray-800 dark:text-gray-100'
          ></input>
          <MagnifyingGlass />
        </div>
        {posts.map((post, idx) => (
          <div key={idx}>
            <hr className='grey-200 h-1 mb-10'></hr>
            <PostCard key={idx} {...post} />
          </div>
        ))}
      </div>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

Try running npm run dev. Your screen should look like this:

Image description

Part Two
https://dev.to/dbrownsoftware/how-to-build-a-developer-blog-using-next-js-13-and-contentlayer-part-two-bp4

Top comments (0)