Improve Blog Listing with Post Excerpts
This is part 6 in the Build a Blog with Next, MDX, and Tailwind series
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: '',
+ })
const mdxSource = await serialize(content)
return { mdxSource, data }
}
Now in our content, we can add that “ 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: '',
})
- 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: '',
})
+ 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: '',
})
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: '',
})
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 “ 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.
+
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,
}
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.
Check back next week for the next article in the series, Support Syntax Highlighting and Heading Links with MDX & Rehype!