← Back
A picture of Caleb, wearing glasses and a jacket

Jan 6, 2024

Let's add a blog to your Next.js 14 site

Recently, I've finished my (seemingly yearly) portfolio update. The tech stack is simple: Next.js, Tailwind, and MDX all hosted on Netlify.


Next.js has been skyrocketing in popularity, especially with the release of v13 which introduced the app directory. With this, a new wave of updated tutorials and issues have arised and while there are many other tutorials on building blogs in Next.js, I figured I might as well also write a simple and easy to understand tutorial.

0. Install the dependencies

To begin, we are going to need the following packages:

- @mdx-js/loader

- @mdx-js/mdx

- @mdx-js/react

- @next/mdx

- @types/mdx

- next-mdx-remote

- gray-matter


For npm users:

npm install @mdx-js/loader @mdx-js/mdx @mdx-js/react @next/mdx
@types/mdx next-mdx-remote gray-matter

1. Configure Next.js

In your next.config.js, add the following:

const withMDX = require('@next/mdx')()

/** @type {import('next').NextConfig} */
const nextConfig = {
    pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'mdx']
    // ...the rest of your config
}

module.exports = withMDX(nextConfig)

2. Build some helper functions

The way we'll programmatically add our .mdx files to our blog is through Node's fs library. This will allow us to create a .mdx file in a folder (in this post our folder will be /posts) and have Next.js automatically show the blog on the list at the /blogs endpoint.


First, create a posts.ts file somewhere in your directory. I usually put this under a "/lib" folder. For those who are not using typescript, omit the export type Post portion.

import fs from "fs/promises";
import matter from "gray-matter";
import path from "path";

export type Post = {
  title: string;
  slug: string;
  date: string;
  description: string;
  body: string;
};

export async function getPosts() {
  const posts = await fs.readdir("./posts/");

  return Promise.all(
    posts
      .filter((file) => path.extname(file) === ".mdx")
      .map(async (file) => {
        const filePath = `./posts/${file}`;
        const fileContent = await fs.readFile(filePath, "utf8");
        const { data, content } = matter(fileContent);

        return { ...data, body: content } as Post;
      })
  );
}

export async function getPost(slug: string) {
  const posts = await getPosts();
  return posts.find((post) => post.slug === slug);
}

Here, we are grabbing the folder named "posts" using fs. Then, we filter through all the files of the folder and only return the files ending in ".mdx". From there, we fill each item in the array with it's corresponding markdown data, such as the body.


The second function, getPost(), simply returns the item in the array that matches the slug parameter.

3. Create the /blog endpoint

In your /app directory, create a folder named "blog". Inside of the newly created folder, create a file named page.tsx and another folder named "[slug]". Create a page.tsx in this folder aswell. Your directory should look like this:

app/
└── blog/
    ├── page.tsx
    └── [slug]/
        └── page.tsx


It's pretty straightforward. The blog endpoint will display a list of all of our posts while blog/[slug] will display a singular blog post.

4. Create custom markdown components (optional)

In order to customize our markdown, we'll create a markdown.tsx file (or name it whatever you want). In this file, we will declare an object that can override the HTML produced by the markdown renderer.


In this example, I'm adding an underline and a hover effect to all <a> tags.

import { MDXComponents } from "mdx/types";

export const Markdown: MDXComponents = {
  a: ({ children, ...props }) => {
    return (
      <a
        {...props}
        className="underline hover:text-blue-600 duration-100"
        target="_blank"
      >
        {children}
      </a>
    );
  },
};

5. Create a Post conmponent

Next, we'll make a component called post.tsx that will contain our markdown renderer. Notice how we are importing our component from the previous step and adding it to the components prop.

import { MDXRemote } from "next-mdx-remote/rsc";
import { Markdown } from "./markdown";
export function Post({ children }: { children: string }) {
  return (
    <MDXRemote
      source={children}
      options={{
        mdxOptions: {},
      }}
      components={Markdown}
    />
  );
}


This component simply renders the markdown using next-mdx-remote, taking advantage of React Server Components. In the options field, you can configure the renderer to your liking such as adding remark or rehype plugins.

6. Create a post!

Obviously we are going to need a post to render. Add a new folder called posts (or whatever directory you named it in step 2) to your root directory. My directory looks like this:

.
├── posts/ <-- here!
├── public/
├── src/
│   ├── app/
│   │   └── blog/
│   │       ├── page.tsx
│   │       └── [slug]/
│   │           └── page.tsx
│   └── lib
├── next.config.js
├── package-lock.json
└── package.json

From there, create a new file called my-first-post.mdx and add the following to the top of the file:

---
title: My first blog post!
description: Read my first post on my blog built with Nextjs
slug: my-first-post
date: Jan 6, 2024
---

This is the metadata of the file, it doesn't actually render any markdown. These fields are useful for things such as rendering the title of the post or using the slug for your URL endpoint (ex: look at the url of the site you're on!).


To add some content, we can add:

### Title!

Hello world!

right below the metadata.

7. Putting it all together

We're almost done! The final step is to code our page.tsx files.


In your blog/page.tsx file, add the following:

import { getPosts } from "@/lib/posts";

export default async function Page() {
  const posts = await getPosts();

  return (
    <div>
      {posts
        .sort((a, b) =>
          new Date(b.date).getTime() - new Date(a.date).getTime())
        .map((post) => (
          <article key={post.slug}>
            <a href={`/blog/${post.slug}`}>
              <p>{post.date}</p>
              <h1>{post.title}</h1>
              <p>{post.description}</p>
            </a>
          </article>
        ))}
    </div>
  );
}

In this code, we can take advantage of server components and call getPosts(). It's a simple await call! Doesn't it look so easy? Then, we sort the posts by the most recent date and render each post item. You can customize it any way you want. Head to your /blog endpoint and see the result!


In your blog/[slug]/page.tsx file, add the following:

import { getPost, getPosts } from "@/lib/posts";
import { Post } from "@/components/post";
import { notFound } from "next/navigation";
import Link from "next/link";

export async function generateStaticParams() {
  const posts = await getPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

export default async function Page({ params }: {
  params: { slug: string } }) {

  const post = await getPost(params.slug);
  if (!post) return notFound();

  return (
    <div>
      <h1>{post.title}</h1>
      <Post>{post.body}</Post>
    </div>
  );
}

In just a few lines of code, we fetch our post and return notFound() to any endpoint that doesn't exist. Then, we use the Post component we made earlier to render our post body. Remember the metadata from earlier? We used the title field to add the post's title to our page.


Go to /blog/my-first-post and see the result!

Wrap up

There it is! You just added a MDX-based blog to your Next.js site. Every .mdx file you add to your posts folder will automatically show up on your site.


Hope you have a great day,
-Caleb