Minimal Next.js Blog (Part 5 - Miscellaneous pages)
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 5, Welcome. In this part, I add support for miscellaneous pages, such as about me, where I work, etc. If Markdown formatting is good enough for blogging, then I figure it's good enough for miscellaneous pages.
I decide that the markdown files for miscellaneous pages will live under the miscellaneous
folder. The main reason is that the Next.js framework has taken the folder pages
, so I can't use that one.
If you recall, blog posts use the route format of /blog/{post-slug}
. This is a sub-level route, as the blog posts live under the top-level /blog/
route. For miscellaneous pages, I decide to use the top-level route of /{page-slug}
. Using a top-level route means cleaner URLs. Additionally, because this is a basic blog site, I assume I'll have only a few miscellaneous pages.
Refactor time
Refactoring is good, as sharing logic and only having one place to update code is great for maintainability. It is, however, not great for blogging, as I'm finding out. I've come too far to throw caution to the wind, though, so on with a refactor.
First, I add a utilities.ts
file under the build-time
directory.
I then add sharable logic to convert Markdown to Html.
export const covertMarkdownToHtml = async (content: string): Promise<string> =>
(await unified().use(markdown).use(highlight).use(html).process(content)).toString()
With that sharable function declared, I update the [slug].tsx
file, which renders blog pages to the browser screen.
It changes from:
const html = await unified().use(markdown).use(highlight).use(html).process(content)
to:
const html = await covertMarkdownToHtml(content)
Next, I add sharable logic to get Markdowns files and their content.
export const getMarkdownFileNames = async (subPath: string): Promise<string[]> =>
(await readdir(`${process.cwd()}/${subPath}`)).filter((fn: string) => fn.endsWith('.md'))
export const readFile = (subPath: string, fileName: string): Buffer => readFileSync(`${process.cwd()}/${subPath}/${fileName}`)
With this sharable logic defined, I update the build-time/posts.ts
file to use it.
The code for getPostsMarkdownFileNames
changes from
export const getPostsMarkdownFileNames = async (): Promise<string[]> =>
(await readdir(`${process.cwd()}/posts`)).filter((fn: string) => fn.endsWith('.md'))
to
export const getPostsMarkdownFileNames = async (): Promise<string[]> => getMarkdownFileNames('posts')
And, the code for readPostFile
changes from
export const readPostFile = (fileName: string): Buffer => readFileSync(`${process.cwd()}/posts/${fileName}`)
to
export const readPostFile = (fileName: string): Buffer => readFile('posts', fileName)
My first miscellaneous page
I add a simple first page as I need one for testing. I create an about.md
file in the miscellaneous
directory. Then I add a small amount of markdown.
# About me
I am me. Who am I? Me. That is all.
See, I told you it was small 😂
Showing a miscellaneous page
I start by adding a [slug].tsx
file to the pages
directory. This file handles all the top-level routes, which for the moment, are miscellaneous pages.
I define this props type for the soon-to-be added MiscellaneousPage
component.
interface IMiscellaneousPage {
html: string
}
As you can see, the prop is very simple and only takes a string of HTML.
Like the other pages I've added so far, this page will have minimal content. Therefore, my component is relatively simple - as planned.
const MiscellaneousPage = (props: IMiscellaneousPage) => {
return (
<>
<header>
<p>My Blog</p>
</header>
<main>
<section dangerouslySetInnerHTML={{ __html: props.html }}></section>
</main>
<footer>
<p>Author: Wade Baglin</p>
</footer>
</>
)
}
I need to define the getStaticPaths
and getStaticProps
functions so that Next.js can display the pages. I won't detail what these functions do, as this is covered in previous posts in this series. Additionally, I won't describe how I've implemented them, as it was covered previously, and I'd likely bore you if I did.
export const getStaticProps: GetStaticProps = async (context: GetStaticPropsContext): Promise<{ props: IMiscellaneousPage }> => {
const slug = context.params!.slug
const { content } = matter(readFile('miscellaneous', `${slug}.md`))
const html = await covertMarkdownToHtml(content)
return {
props: {
html
}
}
}
export const getStaticPaths: GetStaticPaths = async (): Promise<{
paths: Array<string | { params: { slug: string } }>
fallback: boolean
}> => {
const markdownFileNames = await getMarkdownFileNames('miscellaneous')
const markdownFileNamesWithoutExtensions = markdownFileNames.map((fileName) => fileName.replace('.md', ''))
return {
paths: markdownFileNamesWithoutExtensions.map((slug) => {
return {
params: {
slug: slug
}
}
}),
fallback: false
}
}
And lastly, I add a link to my miscellaneous page in the index.tsx
component. You'll notice the link to the about page is static. I decided that because there will only every be a handful of miscellaneous pages, the effort to making this links dynamic is not worth it.
<section>
<h2>Pages</h2>
<Link href="/about">
<a>About</a>
</Link>
</section>
Demo
When I start the dev server and load the index page, I see a link to my miscellaneous page. Nice 👍
And when I click the link, I'm shown the about page. Again - Nice 👍
In part 6 I deploy and host my blog in Azure. 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 { getDistinctCategories, sortBlogMetaDescending } from '../shared/posts'
import { getBlogMetadata } from '../shared/build-time/posts'
export interface IBlogMetadata {
title: string
snippet: string
slug: string
categories: IBlogCategory[]
date: string
}
export interface IBlogCategory {
name: string
slug: string
}
interface IIndexProps {
blogs: IBlogMetadata[]
}
const IndexPage = (props: IIndexProps) => {
const distinctCategories = getDistinctCategories(props.blogs)
const sortedPosts = sortBlogMetaDescending(props.blogs)
return (
<>
<header>
<p>My Blog</p>
</header>
<main>
<h1>Home page</h1>
<section>
<h2>Pages</h2>
<Link href="/about">
<a>About</a>
</Link>
</section>
<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.slug}>
<Link href={`/blog-category/${category.slug}`}>
<a>{category.name}</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 getBlogMetadata()
return {
props: { blogs }
}
}
The full source for /pages/blog/[slug].tsx
import React from 'react'
import matter from 'gray-matter'
import { IBlogMetadata } from '../index'
import { GetStaticProps, GetStaticPaths, GetStaticPropsContext } from 'next'
import { extractBlogMeta } from '../../shared/posts'
import { getPostsMarkdownFileNames, readPostFile } from '../../shared/build-time/posts'
import { covertMarkdownToHtml } from '../../shared/build-time/utilities'
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 html = await covertMarkdownToHtml(content)
return {
props: {
blogMeta,
html
}
}
}
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 /pages/[slug].tsx
import React from 'react'
import matter from 'gray-matter'
import { GetStaticProps, GetStaticPaths, GetStaticPropsContext } from 'next'
import { covertMarkdownToHtml, getMarkdownFileNames, readFile } from '../shared/build-time/utilities'
interface IMiscellaneousPage {
html: string
}
const MiscellaneousPage = (props: IMiscellaneousPage) => {
return (
<>
<header>
<p>My Blog</p>
</header>
<main>
<section dangerouslySetInnerHTML={{ __html: props.html }}></section>
</main>
<footer>
<p>Author: Wade Baglin</p>
</footer>
</>
)
}
export default MiscellaneousPage
export const getStaticProps: GetStaticProps = async (context: GetStaticPropsContext): Promise<{ props: IMiscellaneousPage }> => {
const slug = context.params!.slug
const { content } = matter(readFile('miscellaneous', `${slug}.md`))
const html = await covertMarkdownToHtml(content)
return {
props: {
html
}
}
}
export const getStaticPaths: GetStaticPaths = async (): Promise<{
paths: Array<string | { params: { slug: string } }>
fallback: boolean
}> => {
const markdownFileNames = await getMarkdownFileNames('miscellaneous')
const markdownFileNamesWithoutExtensions = markdownFileNames.map((fileName) => fileName.replace('.md', ''))
return {
paths: markdownFileNamesWithoutExtensions.map((slug) => {
return {
params: {
slug: slug
}
}
}),
fallback: false
}
}
The full source for /shared/build-time/posts.ts
import matter from 'gray-matter'
import { IBlogMetadata } from '../../pages'
import { extractBlogMeta } from '../posts'
import { getMarkdownFileNames, readFile } from './utilities'
export const getPostsMarkdownFileNames = async (): Promise<string[]> => getMarkdownFileNames('posts')
export const readPostFile = (fileName: string): Buffer => readFile('posts', fileName)
export const getBlogMetadata = async (filterCategorySlug?: string): Promise<IBlogMetadata[]> => {
const postFileNames = await getPostsMarkdownFileNames()
const blogMetadata = postFileNames.map((fileName: string) => {
const { data } = matter(readPostFile(fileName))
return extractBlogMeta(data)
})
return !filterCategorySlug
? blogMetadata
: blogMetadata.filter((blogMetadata) => blogMetadata.categories.some((c) => c.slug === filterCategorySlug))
}
The full source for /shared/build-time/utilities.ts
import unified from 'unified'
import markdown from 'remark-parse'
import highlight from 'remark-highlight.js'
import html from 'remark-html'
import { readdir, readFileSync } from 'fs-extra'
export const covertMarkdownToHtml = async (content: string): Promise<string> =>
(await unified().use(markdown).use(highlight).use(html).process(content)).toString()
export const getMarkdownFileNames = async (subPath: string): Promise<string[]> =>
(await readdir(`${process.cwd()}/${subPath}`)).filter((fn: string) => fn.endsWith('.md'))
export const readFile = (subPath: string, fileName: string): Buffer => readFileSync(`${process.cwd()}/${subPath}/${fileName}`)
Date May 5, 2022