Avatar

Hello, I'm Julia.

Setting up a NextJS Markdown Blog with Typescript

#markdown #nextjs #react #typescript

15 min read

Update: I've written a post around the different page rendering options in NextJS that might be worth checking out first, if you're new to NextJS.

NextJS has been all the rage, especially in React-land which is where I tend to reside. There's no better way of learning (in my humble opinion), than actually trying to build something from scratch, so what better than experimenting on my personal website. ๐Ÿ˜† It was well due for an upgrade anyway, as I was using Ruby with Middleman as my static site generator. And whilst Middleman does the job, it isn't really maintained much anymore, and functionality was starting to get stale.

So first things first, here are my basic requirements:

  • Capable of hosting multiple blogs.
  • Able to parse blog posts written in markdown.
  • Proper syntax highlighting of code snippets in the UI.
  • Quick build process with blazing speeds on the front end.
  • Capable of being hosted for free e.g. on Netlify or Vercel, with one click deploy to production through Github.
  • Easily extensible in the future if I want to venture beyond static blog pages.
  • Allows me to use Typescript.

NextJS hosted on Vercel (the optimal solution, as Vercel created NextJS) handles all of the above easily, though the second and thirds points on markdown blog posts needs a bit more configuration. Other parts of the set up I stumbled over were the NextJS specific concepts of getStaticProps and getStaticPaths.

I therefore thought I'd write a blog post about how I got things set up because I found the process rather confusing at first. Now that everything is in place and running smoothly, it all makes sense, but it sure didn't at the time. ๐Ÿ˜…

A quick note before we start - I tend to keep my Typescript types in a separate file. I'll show you what this looks like right at the bottom of the article, in the Appendix, for your reference.

Step 1: Set up NextJS.

  • Create the app: yarn create next-app --typescript and enter the name of your app (read the setup guide here).
  • Check that all is running with yarn dev on localhost:3000.
  • Configure the tsconfig.json file as per your preference.

Step 2: Set up the skeleton page structure of your site.

  • The pages directory is where the routes for your site are automatically determined by NextJS.
  • Create new .tsx files for the different pages you want in your site. In our case, let's just have the one called blog.tsx. This means it will be accessible at yourdomain.com/blog.
  • Clear out the boilerplate content in the index.tsx page and add a link to blog using Next's Link component.
<Link href="/blog">
  <a>Blog</a>
</Link>
  • Let's also delete the api folder as we won't be calling an external API to grab our blog data (all the markdown files that make up our blog posts will be stored in our repo).
  • NextJS's routing also supports nested routes. So in the case of our blog, if we want to have a single post accessible at say yourdomain.com/blog/post1, we'll need to create a folder called blog. Within this folder, create a template for what a single blog post will look like by creating a new .tsx file, with its name in square brackets e.g. [slug].tsx. The square brackets tells NextJS that this is a variable file name.

Step 3: Create your first markdown file for your blog.

You don't necessarily need to do this at this point, but I think it's helpful for illustrating how blog content flows from your head, to the markdown file, to then be parsed by NextJS and shown to the reader.

  • Create a new folder called data (that sits at the same level as your pages folder), and within that, another folder called blog (or whatever other name you prefer).
  • Create a file with a .md extension, within this new folder e.g. post1.md.
  • Start the file by entering the frontmatter you want to define, then continue writing your content in markdown below that. The format needs to look something like this:
---
title: How To Run AB Tests In React
description: 4 options I came across
publishedDate: 2021/02/14
tags:
  - react
  - ab testing
---

Start writing markdown here...
  • Note that you can define whatever frontmatter you want - you don't need to follow what I have above. Tags will be parsed later as an array of strings that looks like ['react', 'ab testing'].

Step 4: Set up the functionality needed to grab all your posts from your data folder, along with their relevant frontmatter.

So it was around this point when I started getting confused around all the options for how we enable NextJS to pull blog post data from markdown files. There are a host of different packages you can use to achieve the same effect, so this is just one of many combos that worked for me.

There was quite a lot of trial and error to get to this point, but I'm happy with how it's all set up now. So here we go:

  • yarn add react-markdown gray-matter react-syntax-highlighter to install the packages.
  • Create a new folder called lib and add a utils.ts file to store some reusable functions for getting posts and their frontmatter. Note that you don't need to do this, but as I was going to have multiple blogs in my app, this helped to keep my code DRY. Here's what my utility functions look like. You'll see what each of these functions are used for in a later step.
  • Note that the argument dataType refers to blog in our case. Where I have multiple blogs on my site, I can just pass in the name of my data subfolder which is what makes these functions reusable e.g. on my site, I also have a separate data folder called books to store my book summaries.
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'

const root = process.cwd()

export async function getFiles(dataType: string) {
  return fs.readdirSync(path.join(root, 'data', dataType), 'utf-8')
}

export async function getPostBySlug(dataType: string, slug: string) {
  const source = fs.readFileSync(path.join(root, 'data', dataType, `${slug}.md`), 'utf8')

  const { data, content } = matter(source)

  return {
    frontMatter: data,
    markdownBody: content,
  }
}

export async function getAllPostsWithFrontMatter(dataType: string) {
  const files = fs.readdirSync(path.join(root, 'data', dataType))

  // @ts-ignore
  return files.reduce((allPosts, postSlug) => {
    const source = fs.readFileSync(path.join(root, 'data', dataType, postSlug), 'utf8')
    const { data } = matter(source)

    return [
      {
        frontMatter: data,
        slug: postSlug.replace('.md', ''),
      },
      ...allPosts,
    ]
  }, [])
}

The gray-matter package takes the string output from readFileSync (which reads a particular markdown file), and spits out an object that nicely separates out your frontmatter from the content. Check out the repo for a more in-depth explanation.

// Input string
'---\ntitle: Front Matter\n---\nThis is content.'

// Output object
{
  content: '\nThis is content.',
  data: {
    title: 'Front Matter'
  }
}

Step 5: Display a list of all your blog posts.

Now back to blog.tsx which is what renders when a user visitors yourdomain.com/blog. We want this to display a list of all the blog posts in data > blog. This is where NextJS's getStaticProps() function comes in. Check out the official docs on what this function does, but essentially, the props generated from this method will be passed to its page component as props during build time.

In our case, we want this page to show:

  • All the posts
  • The title for the blog (optional - I use this for meta tags)
  • The description of the blog (optional - I use this for meta tags)
import { getAllPostsWithFrontMatter } from '@/lib/utils'

export async function getStaticProps() {
  const posts = await getAllPostsWithFrontMatter('blog')

  return {
    props: {
      posts,
      title: 'Blog',
      description: 'Posts on software engineering',
    },
  }
}

The other thing we need on this page is the HTML and content we want to display, including a section were we will render a list of the blog posts. Here's an example:

import BlogPosts from '@/components/BlogPosts'
import CustomLink from '@/components/CustomLink'
import SiteContainer from '@/components/SiteContainer'
import { getAllPostsWithFrontMatter } from '@/lib/utils'
import { BlogProps } from 'types'

export default function Blog({ posts, title, description }: BlogProps) {
  return (
    <SiteContainer title={title} description={description}>
      <div>
        <section className="blog-posts">
          <p>
            I'm trying to solidify my learnings and help others at the same time by writing these short blog posts. I
            generally write about problems I come across and how I solved them. I'll occassionally also write about my
            personal experiences of navigating my career as a software engineer.
          </p>
          <p>
            If you spot an error, or have any comments, suggestions or questions about what I've written, contact me on
            Twitter <CustomLink href="https://twitter.com/bionicjulia">@bionicjulia</CustomLink> or email{' '}
            <CustomLink href="mailto:[email protected]">hello at bionicjulia.com</CustomLink>. I'd love to hear from
            you. ๐Ÿค“
          </p>
          <h3>โœ๐Ÿผ Blog posts on my experience as a software engineer</h3>
          <BlogPosts posts={posts} />
        </section>
      </div>
    </SiteContainer>
  )
}

export async function getStaticProps() {
  const posts = await getAllPostsWithFrontMatter('blog')

  return {
    props: {
      posts,
      title: 'Blog',
      description: 'Posts on software engineering',
    },
  }
}

Side notes:

  • Don't worry about SiteContainer, that's just a layout component that contains all the content across my site, in addition to settting the meta tags for each page.
  • Similarly, you can replace CustomLink with the standard Link component NextJS gives you out of the box.
  • You might have noticed I import my components using the @ symbol. These are shortcuts that NextJS allows you to setup in tsconfig.json, but you can just import the components in the usual way. If you want to see my setup, checkout the Appendix below.

The more interesting component here is BlogPosts which just renders a list of all the posts we pass into it. Note that posts is what is returned from the getStaticProps function, which itself references the getAllPostsWithFrontMatter utility method we created in Step 4. What that function does is to essentially loop through our data > blog folder, read all the markdown content in each file and return all of the posts' data in an array, comprising objects with the frontMatter and slug for each post.

The BlogPosts component looks like this:

import Link from 'next/link'
import { BlogPostsProps } from 'types'

const BlogPosts = ({ posts }: BlogPostsProps) => {
  return (
    <div className="posts">
      {!posts && <div>No posts!</div>}
      <ul>
        {posts &&
          posts
            .sort(
              (a, b) =>
                new Date(b.frontMatter.publishedDate).getTime() - new Date(a.frontMatter.publishedDate).getTime(),
            )
            .map((post) => {
              return (
                <article key={post.slug} className="post-title">
                  <Link href={{ pathname: `/blog/${post.slug}` }}>
                    <a>{post.frontMatter.title}</a>
                  </Link>{' '}
                  - {post.frontMatter.description}
                  <p>[ {post.frontMatter.tags.join(', ')} ]</p>
                </article>
              )
            })}
      </ul>
    </div>
  )
}

export default BlogPosts

Note the use of the Link component, which allows us to view each individual blog post when we click on the blog post title (that we determined in the YAML frontmatter in Step 3).

Step 6: Determine how each individual blog post is rendered.

This is done in [slug].tsx, where the first thing we need to do is tell NextJS what all of the relevant paths are for each post. In this case, I want the URL to be yourdomain.com/blog/post1 where post1 is the name of the markdown blog post in data > blog i.e. post1.md.

NextJS allows us to do this with the getStaticPaths() function (official docs). This returns an array of paths, autogenerated from our markdown file names, along with params we might want to pass along to that path. Similarly to getStaticProps() , this is pre-rendered at build time. The getFiles function comes from our utility file in Step 4 (again, I did this for reusability across my various blogs but you can have it in this file if you wish).

export async function getStaticPaths() {
  const posts = await getFiles('blog')

  const paths = posts.map((filename: string) => ({
    params: {
      slug: filename.replace(/\.md/, ''),
    },
  }))

  return {
    paths,
    fallback: false,
  }
}

We also need a getStaticProps() function here to pass in the necessary props to this page component, in order to render the frontmatter and markdown body in our UI:

export async function getStaticProps({ params }: Params) {
  const { frontMatter, markdownBody } = await getPostBySlug('blog', params.slug)

  return {
    props: {
      frontMatter,
      markdownBody,
    },
  }
}

Note that we're using another one of our utility functions as defined in Step 4, where this function is effectively returning all of the post content from the markdown file whose name matches the slug argument. params.slug comes from the getStaticPaths() function above and is what is available from the path params when someone visits yourdomain.com/blog/post1.

Still with me? We've just got one other thing to do on this page, and that's to render our markdown body in a way that allows us to highlight our code snippets in the right way. This is where the react-markdown and react-syntax-highlighter packages come in.

import React from 'react'
import ReactMarkdown from 'react-markdown'
import BlogLayout from '@/layouts/BlogLayout'
import { BlogPostProps } from 'types'

const BlogPost = ({ frontMatter, markdownBody }: BlogPostProps) => {
  if (!frontMatter) return <></>

  return (
    <BlogLayout frontMatter={frontMatter}>
      <ReactMarkdown
        allowDangerousHtml={false}
        source={markdownBody}
        renderers={{
          link: (props) => <CustomLink {...props} />,
        }}
      />
    </BlogLayout>
  )
}

BlogLayout is basically just a UI component, and sets out the styling of a blog post, along with setting the meta data for the blog post page for SEO purposes (which is why I pass in frontMatter as props).

ReactMarkdown is the markdown component which accepts the following:

  • source prop whereby we pass in the markdownBody which comes from our getStaticProps() function above;
  • renderers which allows us to render React components in replacement of particular node types in the markdownBody text. In this case, I'm saying I want all link nodes i.e. a links to render my CustomLink component instead (you can use NextJS's Link component here - I just created my own custom component to specifically open external links in a new tab). For a list of node types, check this out (and scroll down to "Node types").

As our blog is a technical blog with a lot of code snippets, we also want to add an additional node for our renderer to recognise, and that's code. CodeBlock is my custom component that I want to render instead, which is where SyntaxHighlighter comes in. The super nice thing about this library is that it allows you to choose specific themes for how your code snippets look. What you currently see for instance, is the vscDarkPlus theme that comes from Prism. Check out the themes here.

import BlogLayout from '@/layouts/BlogLayout'
import { BlogPostProps } from 'types'
import React from 'react'
import ReactMarkdown from 'react-markdown'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { vscDarkPlus } from 'react-syntax-highlighter/dist/cjs/styles/prism'

type CodeBlockProps = {
  language: string
  value: React.ReactNode
}

const CodeBlock = ({ language, value }: CodeBlockProps) => {
  return (
    <div className="code-block">
      <SyntaxHighlighter language={language} style={vscDarkPlus}>
        {value}
      </SyntaxHighlighter>
    </div>
  )
}

const BlogPost = ({ frontMatter, markdownBody }: BlogPostProps) => {
  if (!frontMatter) return <></>

  return (
    <BlogLayout frontMatter={frontMatter}>
      <ReactMarkdown
        allowDangerousHtml={false}
        source={markdownBody}
        renderers={{
          link: (props) => <CustomLink {...props} />,
          code: CodeBlock,
        }}
      />
    </BlogLayout>
  )
}

Alright, so putting it all together, this is what [slug].tsx looks like. Please note the export default BlogPost right at the bottom!

import ReactMarkdown from 'react-markdown'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { vscDarkPlus } from 'react-syntax-highlighter/dist/cjs/styles/prism'

import BlogLayout from '@/layouts/BlogLayout'
import { BlogPostProps } from 'types'
import { getFiles, getPostBySlug } from '@/lib/utils'
import CustomLink from '@/components/CustomLink'
import React from 'react'
import { Params } from 'next/dist/next-server/server/router'

type CodeBlockProps = {
  language: string
  value: React.ReactNode
}

const CodeBlock = ({ language, value }: CodeBlockProps) => {
  return (
    <div className="code-block">
      <SyntaxHighlighter language={language} style={vscDarkPlus}>
        {value}
      </SyntaxHighlighter>
    </div>
  )
}

const BlogPost = ({ frontMatter, markdownBody }: BlogPostProps) => {
  if (!frontMatter) return <></>

  return (
    <BlogLayout frontMatter={frontMatter}>
      <ReactMarkdown
        allowDangerousHtml={false}
        source={markdownBody}
        renderers={{
          code: CodeBlock,
          link: (props) => <CustomLink {...props} />,
        }}
      />
    </BlogLayout>
  )
}

export async function getStaticProps({ params }: Params) {
  const { frontMatter, markdownBody } = await getPostBySlug('blog', params.slug)

  return {
    props: {
      frontMatter,
      markdownBody,
    },
  }
}

export async function getStaticPaths() {
  const posts = await getFiles('blog')

  const paths = posts.map((filename: string) => ({
    params: {
      slug: filename.replace(/\.md/, ''),
    },
  }))

  return {
    paths,
    fallback: false,
  }
}

export default BlogPost

Conclusion

And that's it! Like I said, a bit fiddly to get everything set up, but now that that's done, it all makes sense and is fairly easy to maintain.

From here, to build, just run yarn dev. I signed up for a Vercel free account and hooked that up to my website's Github repo. Vercel auto deploys and gives you preview links for branches that you push to Github, so it's super easy to push to staging and production from your command line.

So what do you think? Was this helpful? Anything I could have made clearer? All constructive suggestions are welcome. ๐Ÿ˜ Talk to me on Twitter or Instagram @bionicjulia!

Appendix

Types

export type BlogFrontMatter = {
  title: string
  description: string
  publishedDate: string
  tags: string[]
}

export type BlogLayoutProps = {
  children: React.ReactNode
  frontMatter: BlogFrontMatter
  wordCount: number
  readingTime: string
}

export type BlogPostProps = {
  slug: string
  siteTitle: string
  frontMatter: BlogFrontMatter
  markdownBody: any
  wordCount: number
  readingTime: string
}

export type BlogPostsProps = {
  posts?: BlogPostProps[]
}

export interface BlogProps extends BlogPostsProps {
  title: string
  description: string
}

Setting up shortcut paths in NextJS

In tsconfig.json add this to your compilerOptions (extend and delete as needed):

"paths": {
    "@/components/*": ["components/*"],
    "@/data/*": ["data/*"],
    "@/lib/*": ["lib/*"],
  }

ยฉ 2016-2024 Julia Tan ยท Powered by Next JS.