My Blog

Minimal Next.js Blog (Part 4 - Show posts in category)

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 4, Welcome. In this part, I render a list of all posts within a category. The logic is essentially a mix of what I used in the pages/index.tsx and blog\[slug].tsx pages.

To start with, I do a minor refactoring of the functionality I want to share between these pages; then, I lay down the required code to render a list of blog posts within the selected category.

Refactor time

I move the logic to create a collection of blog metadata out of the pages/index.tsx page and into the shared file /shared/posts.ts, so I change

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 }
  }
}

to this

export const getStaticProps: GetStaticProps = async (): Promise<{ props: IIndexProps }> => {
  const blogs = await getBlogMetadata()
  return {
    props: { blogs }
  }
}

The newly created function getBlogMetadata is placed in the /shared/build-time/posts.ts file.

export const getBlogMetadata = async (filterCategory?: string): Promise<IBlogMetadata[]> => {
  const postFileNames = await getPostsMarkdownFileNames()
  const blogMetadata = postFileNames.map((fileName: string) => {
    const { data } = matter(readPostFile(fileName))
    return extractBlogMeta(data)
  })
  return !filterCategory
    ? blogMetadata
    : blogMetadata.filter((blogMetadata) => blogMetadata.categories.some((c) => c.toLowerCase() == filterCategory.toLowerCase()))
}

Did you notice I added an optional param to filter the returned collection by a category? Yes 🎉

I also need a share the logic to get a collection of distinct categories and sorted posts. Which changes my IndexPage component from

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())

  // TSX Code
}

to

const IndexPage = (props: IIndexProps) => {
  const distinctCategories = getDistinctCategories(props.blogs)
  const sortedPosts = sortBlogMetaDescending(props.blogs)
 
  // TSX Code

with getDistinctCategories and sortBlogMetaDescending simply becoming this in the ./shared/posts.ts file

export const getDistinctCategories = (blogMetadata: IBlogMetadata[]): string[] =>
  blogMetadata
    .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))

export const sortBlogMetaDescending = (meta: IBlogMetadata[]): IBlogMetadata[] =>
  meta.sort((blogA, blogB) => new Date(blogB.date).getTime() - new Date(blogA.date).getTime())

Sanitised categories

Wouldn't having perfect hindsight would be great?

The problem. I'm using categories as the route slug, and obviously, not all characters play nicely in the URL. I figure if I replace any characters that are not alphanumeric or a hyphen with a hyphen, then it's a pretty easy way to solve this problem. That's my thoughts about it for the moment, and I guess time will tell. Though, that leaves me with another problem. Because I adjust the category text, I either use the modified category as the category text, yuk, or change categories from a simple string collection string[] to a complex type. I opt for the complex type as it seems like a good solution, and I end up using the structure { name: string, slug: string}.

First, I create my sanitiseCategory function, placing it in the /shared/posts.ts file.

const sanitiseCategory = (category: string): string => category.replace(/([^a-z0-9\-])+/gi, '-').toLowerCase()

Then I update the existing extractBlogMeta function to build the complex type.

export const extractBlogMeta = (data: { [key: string]: any }): IBlogMetadata => ({
  title: data['title'],
  snippet: data['snippet'] ?? '',
  slug: data['slug'],
  categories: (data['categories'] ?? []).map((c: string) => ({ name: c, slug: sanitiseCategory(c) })),
  date: data['date']
})

Of course, my IBlogMetadata type defined in the /pages/index.tsx file needs a small refactor too, so I change it from

export interface IBlogMetadata {
  title: string
  snippet: string
  slug: string
  categories: string[]
  date: string
}

to

export interface IBlogCategory { name: string; slug: string }

export interface IBlogMetadata {
  title: string
  snippet: string
  slug: string
  categories: IBlogCategory[]
  date: string
}

Now I alter where I render categories to the screen in the /pages/index.tsx, which changes it from

<h2>Categories</h2>
  {distinctCategories.map((category) => (
  <ul key={category}>
    <Link href={`/blog-category/${category}`}>
      <a>{category}</a>
    </Link>
   </ul>
))}

to

<h2>Categories</h2>
  {distinctCategories.map((category) => (
  <ul key={category.slug}>
    <Link href={`/blog-category/${category.slug}`}>
      <a>{category.name}</a>
    </Link>
   </ul>
))}

My newly created function getBlogMetadata in the /shared/build-time/posts.ts needs a slight change, meaning it goes from

export const getBlogMetadata = async (filterCategory?: string): Promise<IBlogMetadata[]> => {
  const postFileNames = await getPostsMarkdownFileNames()
  const blogMetadata = postFileNames.map((fileName: string) => {
    const { data } = matter(readPostFile(fileName))
    return extractBlogMeta(data)
  })
  return !filterCategory
  return !filterCategorySlug
    ? blogMetadata
    : blogMetadata.filter((blogMetadata) => blogMetadata.categories.some((c) => c.toLowerCase() == filterCategory.toLowerCase()))
}

to

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))
}

Nearly there. One last adjustment I do in the'getDistinctCategories' function changes it from

export const getDistinctCategories = (blogMetadata: IBlogMetadata[]): string[] =>
  blogMetadata
    .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))

to

export const getDistinctCategories = (blogMetadata: IBlogMetadata[]): IBlogCategory[] =>
  blogMetadata
    .map((blogMetadata) => blogMetadata.categories)
    .reduce((acc, val) => [...acc, ...val])
    .filter((value, index, self) => self.findIndex(bc => bc.slug === value.slug) === index)
    .sort((catA: IBlogCategory, catB: IBlogCategory) => catA.slug.localeCompare(catB.slug))

I notice that the updated version of getDistinctCategories could perhaps be optimised. I don't worry about it for now. Being only used at build time, I decide it's not worth the time and effort. However, my view may change if the build time duration starts increasing.

Now I have both human and URL friendly categories and category slugs.

The categories look like they always have, but my category URLs are slug-friendly. For example, http://localhost:3000/blog-category/aws-codedeploy.

Showing blogs posts within a category

Thankfully, refactoring and fixing the category URL is the hardest part of showing blog posts in a category. As I stated earlier, this page's functionality is a mix-up of the /pages/index.tsx and the /pages/blog/[slug].tsx.

I add my BlogCategory component. As you have likely already guessed, it's a new component with new props and minimal output to the screen.

interface IBlogCategoryProps {
  category: string
  blogs: IBlogMetadata[]
}

const BlogCategory = (props: IBlogCategoryProps) => {
  const sortedPosts = sortBlogMetaDescending(props.blogs)

  return (
    <>
      <header>
        <p>My Blog</p>
      </header>
      <main>
        <h1>Category {props.category}</h1>
        <section>
          <h2>Posts</h2>
          {sortedPosts.map((blog) => (
            <article key={blog.slug}>
              <Link href={`/blog/${blog.slug}`}>
                <a>{blog.title}</a>
              </Link>
              <details>{blog.snippet}</details>
            </article>
          ))}
        </section>
      </main>
      <footer>
        <p>Author: Wade Baglin</p>
      </footer>
    </>
  )
}

Next, I define the getStaticPaths function, which, as you might remember, is a way for me to tell Next.js all the static paths that exist for this slug. This component is a one-to-one mapping of the different categories.

export const getStaticPaths: GetStaticPaths = async (): Promise<{
  paths: Array<string | { params: { slug: string } }>
  fallback: boolean
}> => {
  const blogMetadata = await getBlogMetadata()
  const distinctCategories = getDistinctCategories(blogMetadata)

  return {
    paths: distinctCategories.map((category) => {
      return {
        params: {
          slug: category.slug
        }
      }
    }),
    fallback: false
  }
}

With the refactoring I did beforehand, this is now pretty straightforward.

Static paths are only half of the puzzle, so I define the getStaticProps function next.

export const getStaticProps: GetStaticProps = async (context: GetStaticPropsContext): Promise<{ props: IBlogCategoryProps }> => {
  const category = context.params!.slug as string
  const blogMetadata = await getBlogMetadata(category)

  return {
    props: {
      category,
      blogs: blogMetadata
    }
  }
}

Done. The function takes a category and returns the props (a list of blogs for the selected category), which is used by theBlogCategory component to render the blogs within a category to the screen. Nice 👍

Demo

When I fire up the dev server and point my browser to it, I can see all the blog posts within a category.

demo of showing blog posts within a category


In part 5 I support for miscellaneous pages. 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>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 html from 'remark-html'
import highlight from 'remark-highlight.js'
import unified from 'unified'
import markdown from 'remark-parse'
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'

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 /pages/blog-category/[slug].tsx

import React from 'react'
import Link from 'next/link'
import { IBlogMetadata } from '../index'
import { GetStaticProps, GetStaticPaths, GetStaticPropsContext } from 'next'
import { getDistinctCategories, sortBlogMetaDescending } from '../../shared/posts'
import { getBlogMetadata } from '../../shared/build-time/posts'

interface IBlogCategoryProps {
  category: string
  blogs: IBlogMetadata[]
}

const BlogCategory = (props: IBlogCategoryProps) => {
  const sortedPosts = sortBlogMetaDescending(props.blogs)

  return (
    <>
      <header>
        <p>My Blog</p>
      </header>
      <main>
        <h1>Category {props.category}</h1>
        <section>
          <h2>Posts</h2>
          {sortedPosts.map((blog) => (
            <article key={blog.slug}>
              <Link href={`/blog/${blog.slug}`}>
                <a>{blog.title}</a>
              </Link>
              <details>{blog.snippet}</details>
            </article>
          ))}
        </section>
      </main>
      <footer>
        <p>Author: Wade Baglin</p>
      </footer>
    </>
  )
}

export default BlogCategory

export const getStaticProps: GetStaticProps = async (context: GetStaticPropsContext): Promise<{ props: IBlogCategoryProps }> => {
  const category = context.params!.slug as string
  const blogMetadata = await getBlogMetadata(category)

  return {
    props: {
      category,
      blogs: blogMetadata
    }
  }
}

export const getStaticPaths: GetStaticPaths = async (): Promise<{
  paths: Array<string | { params: { slug: string } }>
  fallback: boolean
}> => {
  const blogMetadata = await getBlogMetadata()
  const distinctCategories = getDistinctCategories(blogMetadata)

  return {
    paths: distinctCategories.map((category) => {
      return {
        params: {
          slug: category.slug
        }
      }
    }),
    fallback: false
  }
}

The full source for /shared/posts.ts

import { IBlogCategory, IBlogMetadata } from '../pages'

export const extractBlogMeta = (data: { [key: string]: any }): IBlogMetadata => ({
  title: data['title'],
  snippet: data['snippet'] ?? '',
  slug: data['slug'],
  categories: (data['categories'] ?? []).map((c: string) => ({ name: c, slug: sanitiseCategory(c) })),
  date: data['date']
})

export const getDistinctCategories = (blogMetadata: IBlogMetadata[]): IBlogCategory[] =>
  blogMetadata
    .map((blogMetadata) => blogMetadata.categories)
    .reduce((acc, val) => [...acc, ...val])
    .filter((value, index, self) => self.findIndex((bc) => bc.slug === value.slug) === index)
    .sort((catA: IBlogCategory, catB: IBlogCategory) => catA.slug.localeCompare(catB.slug))

export const sortBlogMetaDescending = (meta: IBlogMetadata[]): IBlogMetadata[] =>
  meta.sort((blogA, blogB) => new Date(blogB.date).getTime() - new Date(blogA.date).getTime())

export const sanitiseCategory = (category: string): string => category.replace(/([^a-z0-9\-])+/gi, '-').toLowerCase()

The full source for /shared/build-time/posts.ts

import { readdir, readFileSync } from 'fs-extra'
import matter from 'gray-matter'
import { IBlogMetadata } from '../../pages'
import { extractBlogMeta } from '../posts'

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 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))
}

Date May 4, 2022