Skip to content

The Easiest Way to Create a Markdown Blog in Next.js

Next.js/

There are so many ways how to create a Markdown blog in Next.js. You can spend a lot of time doing it yourself from scratch.

Personally, I have wasted many hours trying to implement some kind of a cache so I don’t have to read a file every time I want to get its content. Don’t make that mistake.

Following this tutorial, you will learn how to use Contentlayer to do the heavy lifting for you.

You will learn how to build every part of a Markdown blog yourself using Contentlayer with TypeScript and Tailwind CSS. You will learn how to create:

  • A common layout component
  • A list of all blog posts
  • A page for an individual blog post
A Markdown blog made with Contentlayer and Tailwind CSS in Next.js

In the end, I will also show you how to add MDX to your blog so you can use React components inside Markdown.

To see the full code, check out the GitHub repository for this project.

Setting up the Project

Before you can start coding, there’s some stuff you need to install and configure.

But even before that, you need to create a new project.

Creating a Next.js Project

First things first, open your terminal and navigate to somewhere where you want to store the folder of your blog.

Run the following command to create a Next.js project. It will ask you for a name, which will be used as the directory name in which your project files will be stored.

npx create-next-app@latest --typescript

The --typescript argument adds TypeScript to your project, which you can omit if you wish. But, I strongly recommend using TypeScript. It goes very well together with Contentlayer, which you will soon install.

With TypeScript, Contentlayer automatically generates types for your content. It brings type safety and code autocompletion without any extra work from your part.

Before you install anything, let’s clean up a bit. Delete the following things that you won’t need:

  • api folder inside pages folder
  • styles/Home.module.css file
  • favicon.ico and vercel.svg files from public folder

Move pages and styles folders into src directory. Although it’s optional, Next.js supports using src directory out of box. I prefer to organize my projects this way.

Adding Placeholder Content

Create a content directory in your project root folder where you will store your blog posts.

Every post you make needs a unique slug. A slug is the part of URL that identifies a post — example.com/posts/this-is-a-slug.

So, for each post, make a folder inside content and set its name to what you want the slug to be.

Add a post following the content/post-slug/index.md file structure. You should end up with something like this:

content/
├─ hello-world/
│  ├─ index.md

Add some content to the index.md file.

Hello World! This is my first post.

## Heading

A paragraph.

- A list
- Of some
- Related things

If you want to use images in your post, you need to place them inside public folder.

I recommend following the public/post-slug/image-name.jpg file structure to keep each post’s images separately. This way, image names from different posts won’t clash if they are not unique.

The source path of the image should be absolute, starting from public folder. An image placed in public/images/blog-post-slug/image.jpg should be imported like this:

![Alt text](/images/blog-post-slug/image.jpg)

I’ve written a post on how to keep images alongside your Markdown source files with a bit of coding.

Lastly, you should use front matter in your Markdown.

Front matter is used to store extra information about a blog post, like its title or the date when it was published. Contentlayer will separate front matter data from the main content automatically.

Front matter needs to be put between a pair of three dashes (---) above your main content.

---
title: Hello World
date: 2022-01-21T09:00:00.000Z
---

Hello World! This is my first post.

With some content in place, you can finally install and configure Contentlayer.

Setting up Contentlayer

Contentlayer transforms your content to code. You don’t have to worry about reading files, parsing their content and converting it all to objects that your code can work with.

You tell Contentlayer where your content lives and it generates JSON documents that your can then import and use in your code.

Now, go ahead and install Contentlayer.

npm install contentlayer next-contentlayer

Next, update next.config.js to wrap your Next.js config with Contentlayer’s withContentlayer utility.

const { withContentlayer } = require('next-contentlayer');

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  swcMinify: true,
};

module.exports = withContentlayer({
  ...nextConfig,
});

Next, update the tsconfig.json file to include some path related confguration.

{
  "compilerOptions": {
    // ✂️
    "baseUrl": ".",
    "paths": {
      "contentlayer/generated": ["./.contentlayer/generated"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".contentlayer/generated"]
  // ✂️
}

And finally, create a contentlayer.config.js file and add the following config. This will turn your Markdown content into JSON documents that your code can easily work with.

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

export const Post = defineDocumentType(() => ({
  name: 'Post',
  filePathPattern: '**/index.md',
  fields: {
    title: {
      type: 'string',
      required: true,
    },
    date: {
      type: 'date',
      description: 'When the post was published',
      required: true,
    },
  },
  computedFields: {
    slug: {
      type: 'string',
      resolve: post => {
        return post._raw.sourceFileDir;
      },
    },
  },
}));

export default makeSource({
  contentDirPath: 'content',
  documentTypes: [Post],
});

First, you are creating a schema using defineDocumentType that describes your blog posts.

The filePathPattern specifies which files relatively to the content folder are used for blog posts. The **/index.md pattern means that Contentlayer will look inside any subfolder (**) of content for an index.md file.

The fields property describes the data you use in front matter. And the computedFields are properties that can’t be found in the file itself but attached to a document by you. In this case, the slug will created using the folder name where each blog post is stored.

Second, you tell Contentlayer the parent folder of your content and register the schema with makeSource function.

Adding Tailwind CSS

Tailwind CSS is a CSS framework that contains utility classes for every CSS property. Instead of writing CSS, you write a bunch of class names.

I will be using Tailwind CSS in this tutorial to avoid writing CSS, but it is completely optional and you can skip this part.

Otherwise, let’s set up Tailwind CSS.

  1. Run the following command to install Tailwind CSS dependencies

    npm install -D tailwindcss postcss autoprefixer @tailwindcss/typography
    

    The @tailwindcss/typography package is extra, but it adds some nice styling for typography

  2. Initialize Tailwind CSS

    npx tailwindcss init -p
    
  3. Open and adjust the tailwind.config.js file

    /** @type {import('tailwindcss').Config} */
    module.exports = {
      content: [
        './src/pages/**/*.{js,ts,jsx,tsx}',
        './src/components/**/*.{js,ts,jsx,tsx}'
      ],
      theme: {
        extend: {},
      },
      plugins: [
        require('@tailwindcss/typography')
      ],
    };
    
  4. Add Tailwind CSS imports to global.css and delete all other styles in there

    @tailwind base;
    @tailwind components;
    @tailwind utilities;
    

To verify that everything works so far, update the pages/index.tsx file to use Tailwind CSS classes.

export default function Home() {
  return (
    <>
      <h1 className="text-2xl sm:text-3xl font-bold text-slate-900 tracking-tight mb-3">Hello there!</h1>
      <p className="text-slate-700">Welcome to my new blog</p>
    </>
  );
}

And, with npm run dev running, visit localhost:3000 in your browser and check what you see.

Home page styled using Tailwind CSS

You can confirm that Tailwind CSS is working if you see the same styling.

Creating a Reusable Layout Component

Since all pages in a website follow one structure, it makes sense to create a layout component that you can reuse.

The layout should include:

  • A header that’s at the top of the page, including navigation
  • A main content area
  • Optionally, a footer

Start by creating a Header component inside components/layout/Header.tsx.

import Link from 'next/link';

export default function Header() {
  return (
    <header className="mt-2 mb-5">
      <div className="flex justify-between items-center max-w-3xl mx-auto px-5">
        <p className="text-2xl font-extrabold">
          <Link href="/">
            <a title="Go to homepage" aria-label="Go to homepage">
              My Blog
            </a>
          </Link>
        </p>
        <nav>
          <Link href="/">
            <a className="text-blue-600 hover:text-blue-400">Posts</a>
          </Link>
        </nav>
      </div>
    </header>
  );
}

When your navigation grows with more more links, you create a separate Navigation component.

Next, create a Layout component in components/layout/Layout.tsx.

import Header from './Header';

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <Header />
      <main className="px-5 max-w-3xl mx-auto">{children}</main>
    </>
  );
}

Finally, update pages/_app.tsx file to use the Layout component in every page.

import '../styles/globals.css';
import type { AppProps } from 'next/app';
import Layout from '../components/layout/Layout';

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <Layout>
      <Component {...pageProps} />
    </Layout>
  );
}

export default MyApp;

Here’s how the finished layout should look:

Blog layout

You can now add a footer yourself if you want one. But, with a basic layout in place, it’s time to create the blog post pages.

Listing All Posts

Let’s update the index.tsx file inside pages to display a list of posts on the home page.

import { allPosts, Post } from 'contentlayer/generated';
import Link from 'next/link';

export async function getStaticProps() {
  const posts = allPosts.sort((a, b) => {
    if (new Date(a.date).getTime() >= new Date(b.date).getTime()) {
      return -1;
    }

    return 1;
  });

  return {
    props: {
      posts,
    },
  };
}

export default function Home({ posts }: { posts: Post[] }) {
  const noPosts = <p className="text-slate-700">Nothing posted yet.</p>;
  const hasPosts = (
    <ul>
      {posts.map(post => {
        return (
          <li key={post.title} className="flex justify-between mb-3">
            <h2 className="text-lg text-slate-900 hover:text-blue-600">
              <Link href={`/posts/${post.slug}`}>
                <a>{post.title}</a>
              </Link>
            </h2>
            <p className="min-w-[90px] text-right text-slate-500">
              {new Date(post.date).toLocaleDateString('en-CA')}
            </p>
          </li>
        );
      })}
    </ul>
  );

  return (
    <>
      <h1 className="text-2xl sm:text-3xl font-bold text-slate-900 tracking-tight mb-3">
        Hello there!
      </h1>
      <p className="text-slate-700">Welcome to my new blog</p>
      <hr className="my-5" />
      {posts.length === 0 ? noPosts : hasPosts}
    </>
  );
}

This should result in something like this.

Blog home page with list of posts

Creating Post Pages

To create pages in Next.js, you need to create a file inside pages folder. The route of the page will match the file name. Since each post has needs it own route, you need to create a dynamic route.

Dynamic routes are made by wrapping a parameter (the dynamic part) of the route inside square brackets ([]). For a post, you can use its slug as the parameter.

Create a folder posts inside pages and put a [slug].tsx file inside of it. This file structure means that a post can be accessed under /posts/the-slug-of-post route.

The posts folder specifies parent route. You can change posts to anything you like, for example, blog.

If you want your posts to be under root path (/post-slug), put the [slug].tsx right inside pages folder, without a parent folder.

Now, open the [slug].tsx file and add the following code.

import Head from 'next/head';
import { allPosts, type Post } from 'contentlayer/generated';

export async function getStaticProps({ params }: { params: { slug: string } }) {
  const post = allPosts.find(postFromAll => postFromAll.slug === params.slug);

  return {
    props: {
      post,
    },
  };
}

export async function getStaticPaths() {
  const slugs = allPosts.map(post => post.slug);
  const paths = slugs.map(slug => {
    return { params: { slug } };
  });

  return {
    paths,
    fallback: false,
  };
}

export default function Post({ post }: { post: Post }) {
  return (
    <>
      <Head>
        <title>{post.title}</title>
      </Head>
      <article className="prose prose-slate">
        <h1 className="text-center mb-3">{post.title}</h1>
        <p className="text-slate-500 text-center">
          Posted on{' '}
          <time dateTime={post.date} title={new Date(post.date).toString()}>
            {new Date(post.date).toLocaleDateString('en-CA')}
          </time>
        </p>
        <div dangerouslySetInnerHTML={{ __html: post.body.html }} />
      </article>
    </>
  );
}

If you navigate to /posts/hello-world route, you should see your blog post.

Single blog post page

Adding MDX

Contentlayer let’s you choose Markdown or MDX in its config. You can even use both at the same time, which is super handy if you are migrating your blog from plain Markdown to MDX. You don’t need to change every single file extension.

Update the contentlayer.config.js file to use MDX and look for .mdx files.

export const Post = defineDocumentType(() => ({
  // ✂️
  filePathPattern: '**/index.{md,mdx}',
  contentType: 'mdx',
}));

You can set the pattern to **/index.{md,mdx} to be able to use both .md and .mdx files in your project at the same time.

Update [slug].tsx page to use MDX.

// ✂️
import Image from 'next/image';
import { useMDXComponent } from 'next-contentlayer/hooks';

// ✂️

export default function Post({ post }: { post: Post }) {
  const components = {
    Image,
  };
  const MDXContent = useMDXComponent(post.body.code);

  return (
    <>
      <Head>
        <title>{post.title}</title>
      </Head>
      <article className="prose prose-slate lg:prose-xl">
        <h1 className="text-center mb-3">{post.title}</h1>
        <p className="text-slate-500 text-center">
          Posted on{' '}
          <time dateTime={post.date} title={new Date(post.date).toString()}>
            {new Date(post.date).toLocaleDateString('en-CA')}
          </time>
        </p>
        <MDXContent components={components} />
      </article>
    </>
  );
}

Notice that the content now is inside the code and not the html property.

To use components in your Markdown, you must pass them to MDXContent via its components prop. In this case, we pass Next.js Image component.

Now, change the Markdown file extension to .mdx and use the registered component in your content.

---
title: Hello World
date: 2022-01-21T09:00:00.000Z
---

Hello World! This is my first post.

<Image alt="Forest" src="/images/hello-world/image.jpg" width={960} height={720} />

Summary

Congratulations! 🎉 You made it all the way to the end. You now have a great starting point to build an amazing blog.

So far you’ve built a home page that lists all your blog posts and a page for individual posts.

Now it’s up to you to add more features, such as syntax highlighting for code blocks.