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.
-Caleb