Minimal Next.js Blog (Part 3 - Show Post)
This is a multipart series. If you haven't read the previous post, I'd suggest you start at part 1, as all subsequent parts continue on from each other and likely won't make sense as individual units.
Part 3, Welcome. In this part, I render a single blog post to the screen. I add the infrastructure to support handling each blog post page route and the conversion from markdown to HTML.
Third-party Libraries
Although parsing and converting markdown to HTML sounds like a fun exercise, I must resist the temptation to write this myself 😀. I use the package unified. Unified is an interface for processing text using syntax trees, and I use the following unified plugins:
- remark-parse - Parses Markdown to mdast syntax trees
- remark-highlight.js - Highlight code blocks with highlight.js (via lowlight)
- remark-html - Serialize Markdown as HTML
I add these using the following NPM command:
npm install unified remark-parse remark-highlight.js remark-html --save-dev
However, there is one catch. Because I'm not allowing implicit any "noImplicitAny": true
in my TS code and because the package remark-highlight.js
doesn't come with types, and I can't find types on https://definitelytyped.org/, I had to make them myself.
Definition for remark-highlight.js
There are a few easy ways to go about adding definitions in TS. I won't cover these, but the one I usually opt for is the mini @types folder, very similar to the familiar /nodes_modules/@types
.
I add the folder types
and the package folder remark-highlight.js
, and the types file index.d.ts
.
Then, I add the following in the types file, which is just enough to stop the error and give me the small sub-set of API types I need.
declare module 'remark-highlight.js' {
import { Plugin } from 'unified'
interface highlightJs extends Plugin {}
const highlight: highlightJs
export = highlight
}
Blog page routing/display
I can add the file to handle routes to a blog post with the libraries now installed. To do this, I use dynamic route segments. Essentially, this feature allows me to inform Next.js about all the pages that will reside under the /blogs/* segment. Note: As this is a static website, I'm not capturing the route. I'm dynamically building out the known pages under this route at build time.
First, I add the specially named file [slug].tsx
under the folder blog
in the pages
directory.
Page component
A blog page will be a new React component, so I define that first. I start by describing my prop type.
interface IBlogPostProps {
blogMeta: IBlogMetadata
html: string
}
There's not much to it because of the reuse of the IBlogMetadata
type defined in my index.tsx
file. Nice 👍
I then define a component to render the blog to the browser screen. I don't do anything fancy yet, like the index page; my objective is to render something simple on the browser page.
const BlogPostPage = (props: IBlogPostProps) => {
return (
<>
<header>
<p>My Blog</p>
</header>
<main>
<h1>{props.blogMeta.title}</h1>
<section dangerouslySetInnerHTML={{ __html: props.html }}></section>
<p>Date {props.blogMeta.date}</p>
</main>
<footer>
<p>Author: Wade Baglin</p>
</footer>
</>
)
}
Mini refactor
Before adding the Next.js function that builds the props for each [slug].tsx
page, I do a mini refactor of the getStaticProps function in the index.tsx
file. Why might you ask? This is so I may reuse the logic already defined there (DRY).
I change it from
export const getStaticProps: GetStaticProps = async (): Promise<{ props: IIndexProps }> => {
const files = await readdir(`${process.cwd()}/posts`)
const blogs = files
.filter((fileName: string) => fn.endsWith('.md'))
.map((fileName: string) => {
const path = `${process.cwd()}/posts/${fileName}`
const { data } = matter(readFileSync(path)
return { title: data['title'], snippet: data['snippet'] ?? '', slug: data['slug'], categories: data['categories'] ?? [], date: data['date'] }
})
return {
props: { blogs }
}
}
to
export const getPostsMarkdownFileNames = async (): Promise<string[]> =>
(await readdir(`${process.cwd()}/posts`)).filter((fn: string) => fn.endsWith('.md'))
export const readPostFile = (fileName: string): Buffer => readFileSync(`${process.cwd()}/posts/${fileName}`)
export const extractBlogMeta = (data: { [key: string]: any }): IBlogMetadata => ({
title: data['title'],
snippet: data['snippet'] ?? '',
slug: data['slug'],
categories: data['categories'] ?? [],
date: data['date']
})
export const getStaticProps: GetStaticProps = async (): Promise<{ props: IIndexProps }> => {
const blogs = (await getPostsMarkdownFileNames()).map((fileName: string) => {
const { data } = matter(readPostFile(fileName))
return extractBlogMeta(data)
})
return {
props: { blogs }
}
}
Webpack is now a little confused and is trying to reference Node's FS in the web output from the build. To fix this, I move these shared functions to their own files under the new paths ./shared/posts.ts
and ./shared/build-time/posts.ts
. Using a separate folder for my shared code allows me to split the code nicely, and the paths should be a reminder for me to think about where my shared code should live. Doing this also allows webpack to optimise away the code from the build and stop the errors. With that in mind, I now have the following:
/pages/Index.tsx
export const getStaticProps: GetStaticProps = async (): Promise<{ props: IIndexProps }> => {
const blogs = (await getPostsMarkdownFileNames()).map((fileName: string) => {
const { data } = matter(readPostFile(fileName))
return extractBlogMeta(data)
})
return {
props: { blogs }
}
}
/shared/posts.ts
import { IBlogMetadata } from '../pages'
export const extractBlogMeta = (data: { [key: string]: any }): IBlogMetadata => ({
title: data['title'],
snippet: data['snippet'] ?? '',
slug: data['slug'],
categories: data['categories'] ?? [],
date: data['date']
})
/shared/build-time/posts.ts
import { readdir, readFileSync } from 'fs-extra'
export const getPostsMarkdownFileNames = async (): Promise<string[]> =>
(await readdir(`${process.cwd()}/posts`)).filter((fn: string) => fn.endsWith('.md'))
export const readPostFile = (fileName: string): Buffer => readFileSync(`${process.cwd()}/posts/${fileName}`)
Page paths
Next.js needs to know all pages that exist for this slug to build a static website. To supply Next.js this information, I define the getStaticPaths
helper function for my /pages/blog/[slug].tsx
file. To satisfy the contract for getStaticPaths, I return a collection of all blog slugs. With my recent refactor, my code is succinctly:
export const getStaticPaths: GetStaticPaths = async (): Promise<{
paths: Array<string | { params: { slug: string } }>
fallback: boolean
}> => {
const markdownFileNames = await getPostsMarkdownFileNames()
const markdownFileNamesWithoutExtensions = markdownFileNames.map((fileName) => fileName.replace('.md', ''))
return {
paths: markdownFileNamesWithoutExtensions.map((slug) => {
return {
params: {
slug: slug
}
}
}),
fallback: false
}
}
Page content
I've told Next.js about the pages under the blog/*
slug route. However, I also need to supply Next.js with the IBlogPostProps
props per page. The props are needed for Next.js to render each page matching the route. To do this, I define the getStaticProps
function. This function inspects the supplied context: GetStaticPropsContext
and builds the props data for the matching slug. The logic is as follows:
export const getStaticProps: GetStaticProps = async (context: GetStaticPropsContext): Promise<{ props: IBlogPostProps }> => {
const slug = context.params!.slug
const { data, content } = matter(readPostFile(`${slug}.md`))
const blogMeta = extractBlogMeta(data)
const result = await unified().use(markdown).use(highlight).use(html).process(content)
return {
props: {
blogMeta,
html: result.toString()
}
}
}
Demo
If I were to display the sample blog post from part 2 on the screen, it would look like:
As you can see, it's not very fancy 🤣, but it's working.
Additionally, here's a little demo of the whole thing in action. Pretty neat, huh? 🎉
In part 4 I render a list of posts within a category. Like this part, it's amazing (Self Certified).
Source
The full source for /pages/index.tsx
import React from 'react'
import Link from 'next/link'
import { GetStaticProps } from 'next'
import matter from 'gray-matter'
import { getPostsMarkdownFileNames, readPostFile } from '../shared/build-time/posts'
import { extractBlogMeta } from '../shared/posts'
export interface IBlogMetadata {
title: string
snippet: string
slug: string
categories: string[]
date: string
}
interface IIndexProps {
blogs: IBlogMetadata[]
}
const IndexPage = (props: IIndexProps) => {
const distinctCategories = props.blogs
.map((blogMetadata) => blogMetadata.categories)
.reduce((acc, val) => [...acc, ...val])
.filter((value, index, self) => self.indexOf(value) === index)
.sort((catA: string, catB: string) => catA.localeCompare(catB))
const sortedPosts = props.blogs.sort((blogA, blogB) => new Date(blogB.date).getTime() - new Date(blogA.date).getTime())
return (
<>
<header>
<p>My Blog</p>
</header>
<main>
<h1>Home page</h1>
<section>
<h2>Posts</h2>
{sortedPosts.map((blogMetadata) => (
<article key={blogMetadata.slug}>
<Link href={`/blog/${blogMetadata.slug}`}>
<a>{blogMetadata.title}</a>
</Link>
<details>{blogMetadata.snippet}</details>
</article>
))}
</section>
<section>
<h2>Categories</h2>
{distinctCategories.map((category) => (
<ul key={category}>
<Link href={`/blog-category/${category}`}>
<a>{category}</a>
</Link>
</ul>
))}
</section>
</main>
<footer>
<p>Author: Wade Baglin</p>
</footer>
</>
)
}
export default IndexPage
export const getStaticProps: GetStaticProps = async (): Promise<{ props: IIndexProps }> => {
const blogs = (await getPostsMarkdownFileNames()).map((fileName: string) => {
const { data } = matter(readPostFile(fileName))
return extractBlogMeta(data)
})
return {
props: { blogs }
}
}
The full source for /pages/blog/[slug].tsx
import { IBlogMetadata } from '../index'
import { getPostsMarkdownFileNames, readPostFile } from '../../shared/build-time/posts'
import { GetStaticPaths, GetStaticProps, GetStaticPropsContext } from 'next'
import highlight from 'remark-highlight.js'
import { extractBlogMeta } from '../../shared/posts'
import markdown from 'remark-parse'
import matter from 'gray-matter'
import unified from 'unified'
import html from 'remark-html'
interface IBlogPostProps {
blogMeta: IBlogMetadata
html: string
}
const BlogPostPage = (props: IBlogPostProps) => {
return (
<>
<header>
<p>My Blog</p>
</header>
<main>
<h1>{props.blogMeta.title}</h1>
<section dangerouslySetInnerHTML={{ __html: props.html }}></section>
<p>Date {props.blogMeta.date}</p>
</main>
<footer>
<p>Author: Wade Baglin</p>
</footer>
</>
)
}
export default BlogPostPage
export const getStaticProps: GetStaticProps = async (context: GetStaticPropsContext): Promise<{ props: IBlogPostProps }> => {
const slug = context.params!.slug
const { data, content } = matter(readPostFile(`${slug}.md`))
const blogMeta = extractBlogMeta(data)
const result = await unified().use(markdown).use(highlight).use(html).process(content)
return {
props: {
blogMeta,
html: result.toString()
}
}
}
export const getStaticPaths: GetStaticPaths = async (): Promise<{
paths: Array<string | { params: { slug: string } }>
fallback: boolean
}> => {
const markdownFileNames = await getPostsMarkdownFileNames()
const markdownFileNamesWithoutExtensions = markdownFileNames.map((fileName) => fileName.replace('.md', ''))
return {
paths: markdownFileNamesWithoutExtensions.map((slug) => {
return {
params: {
slug: slug
}
}
}),
fallback: false
}
}
The full source for /shared/posts.ts
import { IBlogMetadata } from '../pages'
export const extractBlogMeta = (data: { [key: string]: any }): IBlogMetadata => ({
title: data['title'],
snippet: data['snippet'] ?? '',
slug: data['slug'],
categories: data['categories'] ?? [],
date: data['date']
})
The full source for /shared/build-time/posts.ts
import { readdir, readFileSync } from 'fs-extra'
export const getPostsMarkdownFileNames = async (): Promise<string[]> =>
(await readdir(`${process.cwd()}/posts`)).filter((fn: string) => fn.endsWith('.md'))
export const readPostFile = (fileName: string): Buffer => readFileSync(`${process.cwd()}/posts/${fileName}`)
The full source for /types/remark-highlight.js/index.d.ts
declare module 'remark-highlight.js' {
import { Plugin } from 'unified'
interface highlightJs extends Plugin {}
const highlight: highlightJs
export = highlight
}
Date May 3, 2022