Improve Blog Listing with Post Excerpts

14 min read

If you've been following along, our sample blog site is coming along. Our blog index is currently just listing out our file slugs and displaying them as unstyled links. Let's give this page a bit more data to work with and clean up the styling a bit.

Extracting Post Excerpts with gray-matter

We're using the gray-matter module to grab out raw MDX content and our frontmatter from each post. We can also use it to extract an excerpt from each post.

Let's start by building out a utility function to create a post summary. We'll start by duplicating our existing getPostBySlug function since we need similar functionality.

I'll make a copy of getPostBySlug and name this one getPostSummary.

utils.ts

export async function getPostSummary(slug: string) {
  const filePath = join(postDirectory, `${slug}.md`)
  const source = fs.readFileSync(filePath)
  const { content, data } = matter(source)
  const mdxSource = await serialize(content)
  return { mdxSource, data }
}

Let's update the call to matter(source) to extract an excerpt from our content. We'll pass matter an options object with excerpt set to true, we'll supply an excerpt_seperator and update our destructured result to include the returned excerpt key.

export async function getPostSummary(slug: string) {
  const filePath = join(postDirectory, `${slug}.md`)
  const source = fs.readFileSync(filePath)
- const { content, data } = matter(source)
+ const { content, data, excerpt } = matter(source, {
+   excerpt: true,
+   excerpt_separator: '<!-- end -->',
+ })
  const mdxSource = await serialize(content)
  return { mdxSource, data }
}

Now in our content, we can add that <!-- end --> HTML comment after our first paragraph of content or so and it will be extracted on its own into that excerpt value. I choose this separator to avoid any additional elements in the content on the post itself.

Our excerpt could contain markdown and we don't want the raw markdown syntax displaying in our post previews, so let's keep the serialize step here, but we'll apply it to the excerpt value.

export async function getPostSummary(slug: string) {
  const filePath = join(postDirectory, `${slug}.md`)
  const source = fs.readFileSync(filePath)
  const { content, data, excerpt } = matter(source, {
    excerpt: true,
    excerpt_separator: '<!-- end -->',
  })
- const mdxSource = await serialize(content)
+ const mdxSource = await serialize(excerpt)
  return { mdxSource, data }
}

Getting the Types Right

Before we go any farther with this, we need to do a bit of refactoring to get our types right.

In our blog index, we use a PostSummary interface to explicitly define the type for the summary object. Since we're generating that entirely in the utils.ts file now, we should move that interface and update it according to our updated summary shape.

Let's update utils.ts with our type definition, then we'll update the code to respect the new type definition.

import { MDXRemoteSerializeResult } from 'next-mdx-remote'

export interface PostSummary {
  slug: string
  title: string
  date: string
  mdxSource: MDXRemoteSerializeResult
}

We've imported the MDXRemoteSerializeResult type from next-mdx-remote and added the PostSummary interface. Note that we're exporting this since it'll be used in the blog index file.

I've broken out the meta data values into their own keys here, and used that imported type for the mdxSource key.

Now let's update the getPostSummary function. We'll break out the expected meta data and provide default values in case they aren't defined for a post. We'll then spread that meta data into the return object.

export async function getPostSummary(slug: string) {
  const filePath = join(postDirectory, `${slug}.md`)
  const source = fs.readFileSync(filePath)
  const { content, data, excerpt } = matter(source, {
    excerpt: true,
    excerpt_separator: '<!-- end -->',
  })
+ const metaData = {
+   title: data.title || slug,
+   date: data.date || new Date().toLocaleDateString(),
+   slug,
+ }
  const mdxSource = await serialize(excerpt)
- return { mdxSource, data }
+ return { mdxSource, ...metaData }
}

With that done, the type for getPostSummary will look like this

function getPostSummary(slug: string): Promise<{
  title: any
  date: any
  slug: string
  mdxSource: MDXRemoteSerializeResult<Record<string, unknown>>
}>

Let's be more explicit about this and apply our type to the getPostummary function.

-export async function getPostSummary(slug: string) {
+export async function getPostSummary(slug: string): Promise<PostSummary> {
  const filePath = join(postDirectory, `${slug}.md`)
  const source = fs.readFileSync(filePath)
  ...

Now our type as expressed by the type system is what we expect.

function getPostSummary(slug: string): Promise<PostSummary>

Exposing a List of Post Summaries

Now that we can get a post summary from the slug, we need to expose that object for each slug to render our blog index.

Let's add a new function to utils.ts called getAllPostSummaries.

export function getAllPostSummaries() {
  return Promise.all(getAllPostSlugs().map(getPostSummary))
}

Consume the Post Summaries

It's finally time to turn our attention to the blog index code in pages/blog/index.tsx.

Let's start by replacing the getAllPostSlugs import with getAllPostSummaries and by importing our PostSummary interface.

-import { getAllPostSlugs } from '../../utils'
+import { getAllPostSummaries, PostSummary } from '../../utils'

-interface PostSummary {
-  slug: string
-  title: string
-}

interface BlogProps {
  posts: PostSummary[]
}

Then down in the getStaticProps function, we'll update the code to use our new utility.

export const getStaticProps: GetStaticProps<BlogProps> = async () => {
- const posts: PostSummary[] = getAllPostSlugs().map((slug) => ({
-   slug,
-   title: slug,
- }))
  const posts: PostSummary[] = await getAllPostSummaries()
  return {
    props: { posts },
  }
}

Now if we reload the blog index, the titles from our frontmatter in each markdown file should be shown instead of the slug.

Minor Utility Refactoring

Before we move in to the next step, let's refactor our utils.ts file a bit.

We have the same file reading code in getPostBySlug and getPostSummary. Let's pull that out into its own function. This is a minor change, so if you choose to skip this step and leave that little bit of duplication, that's fine too.

+function readFileBySlug(slug: string) {
+ const filePath = join(postDirectory, `${slug}.md`)
+ return fs.readFileSync(filePath)
+}

export async function getPostBySlug(slug: string) {
- const filePath = join(postDirectory, `${slug}.md`)
- const source = fs.readFileSync(filePath)
+ const source = readFileBySlug(slug)
  const { content, data } = matter(source)
  const mdxSource = await serialize(content)
  return { mdxSource, data }
}

export async function getPostSummary(slug: string): Promise<PostSummary> {
- const filePath = join(postDirectory, `${slug}.md`)
- const source = fs.readFileSync(filePath)
+ const source = readFileBySlug(slug)
  const { content, data, excerpt } = matter(source, {
    excerpt: true,
    excerpt_separator: '<!-- end -->',
  })
  const metaData = {
    title: data.title || slug,
    date: data.date || new Date().toLocaleDateString(),
    slug,
  }
  const mdxSource = await serialize(excerpt)
  return { mdxSource, ...metaData }
}

We're not making use of the content key that is extracted by gray-matter in getPostSummary, so let's remove that as well.

export async function getPostSummary(slug: string): Promise<PostSummary> {
  const source = readFileBySlug(slug)
- const { content, data, excerpt } = matter(source, {
+ const { data, excerpt } = matter(source, {
    excerpt: true,
    excerpt_separator: '<!-- end -->',
  })
  const metaData = {
    title: data.title || slug,
    date: data.date || new Date().toLocaleDateString(),
    slug,
  }
  const mdxSource = await serialize(excerpt)
  return { mdxSource, ...metaData }
}

Content Updates

In order for our excerpts to show up, we need to add the <!-- end --> separator in our posts. I'll use the hello-world.md post as an example.

Maybe there's a **happy little bush** that lives right there. There's nothing wrong with having a tree as a friend. Mountains are so simple, they're hard.

+<!-- end -->

Trees get lonely too, so we'll give him a little friend. This is where you take out all your _hostilities and frustrations_. It's better than kicking the puppy dog around and all that so. Only think about one thing at a time. Don't get greedy. No pressure. Just relax and watch it happen.

That's it, just drop that HTML comment in the markdown file and the excerpt will consist of the content before it. For testing purposes, I've dropped that comment in each of the sample markdown files after the first paragraph.

Formatting the Post Listing

Now that we have a more robust post summary available to us, let's use it in our listing.

Let's start off by replacing the unordered list element with a div, and we'll apply grid styles to it. This will create a 3 column grid.

-<ul>
+<div className="grid auto-rows-min grid-cols-3 gap-5">
  {posts.map((post) => (
    <li key={post.slug}>
      <Link href={`/blog/${post.slug}`}>
        <a>{post.title}</a>
      </Link>
   </li>
  ))}
-</ul>
+</div>

Now we'll move the key onto the Link component and remove the list item element.

<div className="grid auto-rows-min grid-cols-3 gap-5">
  {posts.map((post) => (
-   <li key={post.slug}>
-     <Link href={`/blog/${post.slug}`}>
+     <Link key={post.slug} href={`/blog/${post.slug}`}>
        <a>{post.title}</a>
      </Link>
-  </li>
  ))}
</div>

Let's import the MDXRemote component at the top of the file.

import type { GetStaticProps } from 'next'
import Link from 'next/link'
+import { MDXRemote } from 'next-mdx-remote'
import { getAllPostSummaries, PostSummary } from '../../utils'

And now we can update the content of our post listing.

<div className="grid auto-rows-min grid-cols-3 gap-5">
  {posts.map((post) => (
    <Link key={post.slug} href={`/blog/${post.slug}`}>
-     <a>{post.title}</a>
+     <a>
+       <h2>{post.title}</h2>
+       <MDXRemote {...post.mdxSource} />
+     </a>
    </Link>
  ))}
</div>

At this stage, we have the title and excerpt for each post rendered in a 3 column grid. Let's style this a bit.

<div className="grid auto-rows-min grid-cols-3 gap-5">
  {posts.map((post) => (
    <Link key={post.slug} href={`/blog/${post.slug}`}>
-     <a>
+     <a className="border-2 border-gray-900 rounded-lg py-4 px-5">
-       <h2>{post.title}</h2>
+       <h2 className="text-3xl text-sky-900 text-center font-extrabold">
        <MDXRemote {...post.mdxSource} />
      </a>
    </Link>
  ))}
</div>

Each item now has some padding and a border, the post title is centered and more prominent with a large, bold font.

Clamping the Excerpt

Based on the example content and how these things go in general, it's likely that our excerpts will end up being different lengths. I don't want to have to worry about how that will impact the layout, so we're going to add the line clamp Tailwind plugin so we can control the display length of the excerpt with CSS.

Install The Dependency

Let's install the line-clamp plugin as a dev dependency.

npm i -D @tailwindcss/line-clamp

Configure Tailwind

Just like we did with the typography plugin, we'll require this new module in our tailwind.config.js file.

tailwind.config.js

-plugins: [require('@tailwindcss/typography')],
+plugins: [
+   require('@tailwindcss/typography'),
+   require('@tailwindcss/line-clamp'),
+ ],

Apply the Classes

With line-clamp installed, let's head back to pages/blog/index.tsx and put it to use.

Let's surround the MDXRemote component with a p element, and then we can apply some styling to it.

<Link key={post.slug} href={`/blog/${post.slug}`}>
  <a className="border-2 border-gray-900 rounded-lg py-4 px-5">
    <h2 className="text-3xl text-sky-900 text-center font-extrabold">
      {post.title}
    </h2>
-   <MDXRemote {...post.mdxSource} />
+   <p className="mt-4 text-lg text-gray-800 line-clamp-5">
+     <MDXRemote {...post.mdxSource} />
+   </p>
  </a>
</Link>

We've added some margin to push the paragraph down from the title, and bumped up the font size. We've also applied the line-clamp-5 class that we gained by adding the line-clamp plugin. This plugin will limit our text to 5 lines and add an ellipsis at the end of the final line if it had to hide content.

Fix Problematic Markup

By adding our paragraph around the MDXRemote component, we've created some invalid markup on our page. The excerpt is rendered as a paragraph already and we've wrapped that in our own paragraph.

We also have the potential for other invalid markup in the future. For example, this entire thing is wrapped in a link to our post. If we were to include a markdown link in our content that is then extracted into the excerpt, this will nest a link inside a link which is not valid HTML.

We can correct for these situations and prevent elements that could throw off our styling by controlling how certain components are rendered by MDXRemote.

We'll start by creating a component that accepts children and wraps them in a Fragment.

const Safe = ({ children }) => <>{children}</>

Now, let's add a components object before our Blog component export. In it, we'll define a component to replace the p element with an instance of our Safe component.

const components = {
  p: Safe,
}

Then we'll pass our components object into the MDXRemote instance.

-<MDXRemote {...post.mdxSource} />
+<MDXRemote {...post.mdxSource} components={components} />

Now our markup will not include the extra paragraph. We can do the same thing for any element we want to display without introducing any additional markup or styling. Let's also add a replacement for a elements since we know that could be problematic.

const components = {
  p: Safe,
+ a: Safe,
}
Tip
You might be wondering why we don't just replace these elements directly with Fragment like const components = { p: Fragment }. If we do that, any props that are passed to the original element will be passed to the Fragment and those props are invalid so we'll get console errors.

Displaying Post Dates

Our post summaries still aren't displaying the date, so let's add that to our markup. We'll need to take the date property from the post and convert it to a Date to format it. We'll also need it twice, so we're going to refactor the arrow function a bit so we can create the displayDate value. We'll wrap out date display in a div with some classes applied, and use a time element to display it.

<div className="grid auto-rows-min grid-cols-3 gap-5">
- {posts.map((post) => (
+ {posts.map((post) => {
+ const displayDate = new Date(post.date).toDateString()
+ return (
    <Link key={post.slug} href={`/blog/${post.slug}`}>
      <a className="border-2 border-gray-900 rounded-lg py-4 px-5">
        <h2 className="text-3xl text-sky-900 text-center font-extrabold">
          {post.title}
        </h2>
        <p className="mt-4 text-lg text-gray-800 line-clamp-5">
          <MDXRemote {...post.mdxSource} components={components} />
        </p>
+       <div className="mt-2 text-center text-sky-600 font-semibold">
+         <time dateTime={displayDate}>{displayDate}</time>
+       </div>
      </a>
    </Link>
- ))}
+   )
+ })}
</div>

You'll probably notice two problems here. Our date is right below the excerpt, so it isn't aligned across the items. The bigger problem is that our posts aren't sorted by date. Let's knock out the layout issue, then we'll fix our sorting.

We'll do this by adding the flex and flex-col classes to the a element that wraps the item. Then we'll allow the p element on our excerpt stretch to fill any available space with the flex-1 class.

<Link key={post.slug} href={`/blog/${post.slug}`}>
- <a className="border-2 border-gray-900 rounded-lg py-4 px-5">
+ <a className="border-2 border-gray-900 rounded-lg py-4 px-5 flex flex-col">
    <h2 className="text-3xl text-sky-900 text-center font-extrabold">
      {post.title}
    </h2>
-   <p className="mt-4 text-lg text-gray-800 line-clamp-5">
+   <p className="mt-4 text-lg text-gray-800 line-clamp-5 flex-1">
      <MDXRemote {...post.mdxSource} components={components} />
    </p>
    <div className="mt-2 text-center text-sky-600 font-semibold">
      <time dateTime={displayDate}>{displayDate}</time>
    </div>
  </a>
</Link>

Now our div containing the date will be pushed down to the bottom, even with a short excerpt.

Sorting Post Summaries

Now we need to get our items displayed in the expected order. We want a descending date order, so the most recent items are displayed first.

Since the sort method of an array sorts the items in-place (meaning, it mutates the array), we can do this by adding a single line to out getStaticProps function.

export const getStaticProps: GetStaticProps<BlogProps> = async () => {
  const posts: PostSummary[] = await getAllPostSummaries()
+ posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
  return {
    props: { posts },
  }
}

Now when we reload the blog index, our posts should be sorted in descending order by date.

Final Cleanup

There's just a touch of additional markup and styling I want to add so we can consider this page "done" for now. We still have an unstyled h1 hanging out at the top of the page. Let's update that, and the div that wraps this entire component.

export default function Blog({ posts }: BlogProps) {
  return (
-   <div>
+   <div className="mt-6 pb-6">
-     <h1>Blog Post Listing</h1>
+     <h1 className="text-5xl font-extrabold text-center p-8">Posts</h1>
      <div className="grid auto-rows-min grid-cols-3 gap-5">
...

We've just added some top margin to push everything down away from the header and adding some padding on the bottom so items aren't pushed up against the footer when the items reach or exceed the window height. The h1 is now bigger, bolder, and centered.

You should make the styles your own, but this at least provides some breathing room around elements and creates a bit of visual hierarchy as a starting point.

Hot Tip