back button Back to blog

Convert a Next.js site to a markdown blog

A step-by-step guide to adding a markdown blog to an existing Next.js site.

Noah MatsellDecember 27, 2022
Copy URL

A Markdown-based blog makes writing and publishing content a breeze.

Markdown is a powerful, lightweight markup language that allows you to write structured content using plain text. With Markdown, you can easily format your text and add rich formatting to your blog posts, without the need for complex HTML tags.

If you have a Next.js site and are looking for a better way to write and manage blog content online, Markdown is a tool that merits strong consideration.

Here are the 3 steps you can take to migrate an existing Next.js website to a site that includes a Markdown blog.

The Migration Steps

1. Markdown posts setup

  • Create blog directory at the root of your project.
    • This is where you will write and store your md blog posts.
  • Create a sample post blog/
    • Here's an example .md file with some frontmatter metadata
      title: 'Writing Effective Readmes'
      author: 'Noah Matsell'
      description: 'How to write an effective Readme for your project repository.'
      coverImgUrl: 'images/22-09-2021.jpg'
      date: '2021-09-22'
        - dev tips
      # Introduction
      Text content
  • In your project types folder, create a blog.ts file for your post metadata types. These will represent the frontmatter metadata parsed by your app and are useful when handling blog post data within your React components.
  // types/blog.ts
  export interface Post {
    title: string;
    description: string;
    author: string;
    coverImgUrl: string;
    date: string;
    categories: Category[];
    content: string;
    slug?: string;

2. API Setup

  • Install gray-matter via npm or yarn. - This package is used to parse your md blog post metadata from a string or file.
  • Create a lib/api.ts file to handle getting posts and their contents.
    • Create a getPostSlugs function. This generates an array of slugs from the md files contained within the new blog folder.
    // lib/api.ts
    const postsDirectory = join(process.cwd(), "blog");
    export function getPostSlugs() {
      return fs.readdirSync(postsDirectory);
    • Create a getPostBySlug function. This builds up, parses, and returns post data for a given slug and set of fields.
    // lib/api.ts
    export function getPostBySlug(slug: string, fields: (keyof Post)[] = []) {
      const realSlug = slug.replace(/\.md$/, "");
      const fullPath = join(postsDirectory, `${realSlug}.md`);
      const fileContents = fs.readFileSync(fullPath, "utf8");
      const { data, content } = matter(fileContents);
      const postData: Partial<Post> = {};
      // Ensure only the minimal needed data is exposed
      fields.forEach((field) => {
        // set slug to real slug
        if (field === "slug") {
          postData[field] = realSlug;
        // set content to parsed gray matter content
        if (field === "content") {
          postData[field] = content;
        // set other properties 1:1 if they exist
        if (typeof data[field] !== "undefined") {
          postData[field] = data[field];
        // add [draft] tag to unpublished posts in development
        if (
          process.env.NODE_ENV === "development" &&
          field === "title" &&
          data["title"] &&
        ) {
          postData[field] = data[field] + " [draft]";
      return postData;
    • Create a getAllPosts function. This gets all existing posts and their post data for a given set of fields.
    // lib/api.ts
    export function getAllPosts(fields: (keyof Post)[] = []) {
      const slugs = getPostSlugs();
      const posts = slugs
        .map((slug) => getPostBySlug(slug, fields))
        // Sort posts by date in descending order
        .sort((post1, post2) => {
          return (post1.publishDate || "0") > (post2.publishDate || "0") ? -1 : 1;
      // Filter out unpublished posts in production
      if (process.env.NODE_ENV === "production") {
        return posts.filter((post) => post.publishDate);
      return posts;
    [Full api.ts code]
  • Create a lib/markdownToHtml.ts file. This is needed to translate the md content of a post to html content.
    • Install remark and remark-html via npm or yarn.
    // lib/markdownToHtml.ts
    import { remark } from "remark";
    import html from "remark-html";
    export default async function markdownToHtml(markdown: string) {
      const result = await remark()
        .use(html, { sanitize: false })
      return result.toString();

3. Component Setup

  • Under your pages directory, create a blog directory.

  • Create pages/blog/[slug].tsx, a dynamic route that's responsible for taking your md blog posts and turning them into pages!

    • Make a Post component, which is a React component that renders the parsed content data of a given blog post.
    // pages/blog/[slug].tsx
    interface Props {
      post: PostType;
      morePosts: PostType[];
    export default function Post({ post }: Props) {
      // The component that renders your a blog post.
    • The post component can be composed of Layout, PostHeader, PostBody and/or any other presentational components you like. Post component.
    • Render your parsed post comment anywhere inside of the Post component. Or, create a child PostBody component:
      // components/PostBody.tsx
      import markdownStyles from "../styles/markdown-styles.module.css";
      const PostBody = ({ content }: Props) => {
        return (
          <div className="max-w-2xl mx-auto">
              dangerouslySetInnerHTML={{ __html: content }}
  • This is where your custom styling and CSS can be applied to the rendered post content. In the example above, I've created markdown-styles.module.css and applied it to the rendering div tag.

  • Make a getStaticProps fn

    • Exporting a function called getStaticProps will pre-render a page at build time using the props returned from the function
    // pages/blog/[slug].tsx
    export async function getStaticProps({ params }: Params) {
      const post = getPostBySlug(params.slug, [
      const content = await markdownToHtml(post.content || "");
      return {
        props: {
          post: {
    • The props object is a key-value pair, where each value is received by the page component. It should be a serializable object so that any props passed, could be serialized with JSON.stringify.

    getStaticProps diagram

  • Make a getStaticPaths fn

    • When exporting a function called getStaticPaths from a page that uses Dynamic Routes, Next.js will statically pre-render all the paths specified by getStaticPaths.
    // pages/blog/[slug].tsx
    export async function getStaticPaths() {
      const posts = getAllPosts(["slug"]);
      return {
        paths: => {
          return {
            params: {
              slug: post.slug,
        fallback: false,
    • The paths key determines which paths will be pre-rendered.
    • This is ultimately how we dynamically create all of the blog post pages at build time for the posts in our blog/ directory.

getStaticPaths diagram

[Full [slug].tsx code]

Wrapping up

With these steps in place, your Next.js website will now be able to render Markdown blog content, and you can easily manage and update your blog content using the Markdown syntax.

Your final project structure should look something like this:

├── blog
├── components
 ├── HeroPost.tsx
 ├── Layout.tsx
 ├── MorePosts.tsx
 ├── PostBody.tsx
 ├── PostHeader.tsx
 └── PostTitle.tsx
├── lib
 ├── api.ts
 └── markdownToHtml.ts
├── pages
 ├── _app.tsx
 ├── api
 ├── blog
  ├── [slug].tsx
  └── index.tsx
 └── index.tsx
├── styles
 ├── blog.css
 ├── globals.css
 └── markdown-styles.module.css
├── next.config.js
├── package.json
├── postcss.config.js
├── public
├── tailwind.config.js
├── tsconfig.json
└── types
    └── blog.ts

Next steps

Here are some ideas for extending the functionality of your markdown blog in cool ways:

  • Add code highlighting via Prism
  • Make a blog 'homepage'
  • Add a dynamic table of contents
  • Add a share button using the Web Share API

Like this post?

Sign up and get notified when new posts are published!