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
onlocalhost: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 calledblog.tsx
. This means it will be accessible atyourdomain.com/blog
. - Clear out the boilerplate content in the
index.tsx
page and add a link toblog
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 calledblog
. 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 yourpages
folder), and within that, another folder calledblog
(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.
- react-markdown - A markdown component for React that uses remark.
- gray-matter - YAML frontmatter parser.
- react-syntax-highlighter (using the Prism subpackage) - A syntax highlighter for React.
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 autils.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 toblog
in our case. Where I have multiple blogs on my site, I can just pass in the name of mydata
subfolder which is what makes these functions reusable e.g. on my site, I also have a separate data folder calledbooks
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 standardLink
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 intsconfig.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 themarkdownBody
which comes from ourgetStaticProps()
function above;renderers
which allows us to render React components in replacement of particular node types in themarkdownBody
text. In this case, I'm saying I want alllink
nodes i.e.a
links to render myCustomLink
component instead (you can use NextJS'sLink
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/*"],
}