Complete Guide: Integrating MDX Blog Feature into Next.js 14 App Router

Kishore Gunnam

Kishore Gunnam / January 01, 2024

7 min read

In the world of web development, keeping your website updated with the latest functionalities can be crucial to ensure you stay ahead of the curve. Next.js, a React framework known for its efficiency and functionality, offers developers a streamlined way to introduce dynamic features into their sites. This article will take you through the process of implementing a fully functional blog feature in your Next.js 14 website. From installing the necessary packages to styling your components, we've got you covered.

Prerequisites

Before diving into the code, ensure you have Next.js 14 set up and running on your system. If you're new to Next.js, you can follow their official getting started guide.

Installing Necessary Packages

Start by adding the packages required to power your blog:

  • next-mdx-remote for parsing markdown files.
  • reading-time to estimate how long it will take to read a post.
  • path and fs for handling file paths and file system operations.
  • mdx-prism, remark-autolink-headings, remark-slug, and remark-code-titles for MDX customization.
  • gray-matter for front-matter parsing.
  • date-fns for date formatting.

Install them using your package manager of choice, e.g., npm or yarn:

npm install next-mdx-remote reading-time path mdx-prism gray-matter fs remark-autolink-headings remark-slug remark-code-titles date-fns

Creating the Blog Library

Create a file under /lib/blog.ts and copy the below code into it.

lib/blog.ts

import fs from "fs"; import matter from "gray-matter"; import mdxPrism from "mdx-prism"; import path from "path"; import readingTime from "reading-time"; import { compileMDX } from "next-mdx-remote/rsc"; import MDXComponents from "@/components/mdx-components"; import RemarkAutolinkHeading from "remark-autolink-headings"; import RemarkSlug from "remark-slug"; import RemarkCodeTitles from "remark-code-titles"; import { ReactElement } from "react"; const root = process.cwd(); interface FrontMatter { [key: string]: any; slug: string | null; } export async function getFiles(type: string): Promise<string[]> { const files = fs.readdirSync(path.join(root, "data", type)); return files; } export async function getFileBySlug(type: string, slug?: string | null): Promise<{ frontMatter: FrontMatter; source: ReactElement }> { const sourcePath = slug ? path.join(root, "data", type, `${slug}.mdx`) : path.join(root, "data", `${type}.mdx`); const source = fs.readFileSync(sourcePath, "utf8"); const { data, content } = matter(source); const { content: generated } = await compileMDX<{ title: string }>({ source: content, options: { mdxOptions: { remarkPlugins: [RemarkAutolinkHeading, RemarkSlug, RemarkCodeTitles], rehypePlugins: [mdxPrism], }, }, components: MDXComponents as any, }); const frontMatter: FrontMatter = { wordCount: content.split(/\s+/gu).length, readingTime: readingTime(content), slug: slug || null, ...data, }; return { frontMatter, source: generated, }; } export async function getAllFilesFrontMatter(type: string): Promise<FrontMatter[]> { const files = fs.readdirSync(path.join(root, "data", type)); return files.map((postSlug: string) => { const source = fs.readFileSync(path.join(root, "data", type, postSlug), "utf8"); const { data } = matter(source); return { ...data, slug: postSlug.replace(".mdx", ""), }; }); }

This library will contain helper functions to read markdown files from the file system, parse them, and retrieve front-matter data for each blog post.

MDX Components

For a cohesive design, create custom MDX components like Image, Button, and CustomLink. These components will allow for greater flexibility and styling within your MDX-rendered blog posts.

components/mdx-components.tsx

import Image, { ImageProps } from "next/image";
import Button from "./button"; 
import CustomLink from "./custom-link";
// add any of your custom react components
 
const MDXComponents = {
  Image: (props: ImageProps) => <Image {...props} />,
  a: CustomLink,
  Button,
 // export from here
};
 
export default MDXComponents;

Craft a CustomLink component that differentiates between internal and external links, ensuring SEO best practices are followed with appropriate rel and target attributes for external links. Develop a Button component to encourage user engagement, making sure to adhere to accessibility standards with proper contrast ratios and ARIA attributes.

components/custom-link.tsx

import React from "react"; import Link from "next/link"; interface CustomLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> { href: string; } const CustomLink: React.FC<CustomLinkProps> = (props) => { const { href, ...rest } = props; const isInternalLink = href && (href.startsWith("/") || href.startsWith("#")); if (isInternalLink) { return ( <Link href={href}> <a {...rest} /> </Link> ); } return <a target="_blank" rel="noopener noreferrer" href={href} {...rest} />; }; export default CustomLink;

components/button.tsx

import React from "react"; interface ButtonProps { text: string; url: string; } const Button: React.FC<ButtonProps> = ({ text, url }) => { return ( <a href={url} style={{ color: "#ffffff", textDecoration: "none" }} className="mr-2 inline-block rounded-md px-6 py-3 bg-black text-gray-50 text-sm border border-gray-100 dark:border dark:border-gray-100" > {text} </a> ); }; export default Button;

The Blog Page

Next, construct the blog overview page that will list all blog posts. Utilize getAllFilesFrontMatter from our blog library to fetch post metadata and display them with a BlogPost component that you'll design to match your aesthetic.

components/blog-post.tsx

import Image from "next/image"; import Link from "next/link"; import { cn } from "@/util/cn"; interface BlogPostProps { title: string; summary: string; slug: string; image: string; } const BlogPost: React.FC<BlogPostProps> = (props: BlogPostProps) => { const { title, summary, slug, image } = props; return ( <Link href={`/blog/${slug}`} className={cn( "w-full shadow-xl dark:bg-zinc-800 dark:border-zinc-700 rounded-md mb-4 p-4 border border-gray-200" )} > <Image className="rounded-md" src={image} alt={title} width={420} height={250} /> <div className="w-full"> <div className="flex flex-col gap-4 md:flex-row justify-between"> <h4 className="text-base blog-heading md:text-xl py-2 font-semibold mb-2 w-full text-gray-900 dark:text-gray-100"> {title} </h4> </div> <button aria-label={title} type="button" className="group mt-2 rounded-full bg-white/90 px-3 py-2 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur transition dark:bg-zinc-900/90 dark:ring-teal-500/50 dark:hover:ring-white/20" > Read more </button> </div> </Link> ); }; export default BlogPost;

app/blog/page.tsx

import { Metadata } from "next"; import BlogPost from "@/components/blog-post"; import { getAllFilesFrontMatter } from "@/lib/mdx"; export const metadata: Metadata = { title: "Blog", description: `Write your description here`, }; export default async function BlogsPage() { const posts = await getAllFilesFrontMatter("blog"); return ( <main className="flex min-h-screen flex-col items-center justify-between"> <div className="w-full max-w-4xl"> <h3 className="font-bold text-2xl md:text-2xl tracking-tight mb-4 mt-8 text-black dark:text-white"> All posts </h3> {!posts.length && ( <p className="text-gray-600 dark:text-gray-400 mb-4"> No posts found. </p> )} <div className="grid grid-cols-1 md:grid-cols-2 w-full"> {posts.map((frontMatter: any) => ( <BlogPost key={frontMatter.title} {...frontMatter} /> ))} </div> </div> </main> ); }

Single Blog Post Page

Structure your single blog post page to utilize getFileBySlug to retrieve the content of a post based on its slug. Format and display the post's metadata, including the title, publish date, and reading time, ensuring that each element is styled for readability.

app/blog/[slug]/page.tsx

import { Metadata } from "next"; import Link from "next/link"; import { parseISO, format } from "date-fns"; import Image from "next/image"; import { getFileBySlug } from "@/lib/mdx"; type Props = { params: { slug: string } searchParams: { [key: string]: string | string[] | undefined } } export async function generateMetadata( { params }: Props, ): Promise<Metadata> { const post = await getFileBySlug("blog", params.slug); return { title: `${post.frontMatter.title}`, openGraph: { images: [post.frontMatter.image], }, } } export default async function Page({ params }: { params: { slug: string } }) { const post = await getFileBySlug("blog", params.slug); const { frontMatter, source } = post; return ( <div className="w-full max-w-3xl mx-auto"> <div className=" items-center gap-1 mb-10 hidden md:flex"> <Link type="button" href="/blog" > Blog </Link> <span className="mx-2 text-gray-400">/</span> <h4>{frontMatter.title}</h4> </div> <h1 className="font-bold text-2xl mt-10 md:text-4xl tracking-tight mb-4 text-black dark:text-white"> {frontMatter.title} </h1> <div className="flex flex-col md:flex-row justify-between items-start md:items-center w-full mt-2 mb-8"> <div className="flex items-center"> <Image alt="Kishore Gunnam" height={24} width={24} src="/kishore.jpeg" className="rounded-full" /> <p className="text-sm text-gray-700 dark:text-gray-300 ml-2"> {frontMatter.by} {"Kishore Gunnam / "} {format(parseISO(frontMatter.publishedAt), "MMMM dd, yyyy")} </p> </div> <p className="text-sm text-gray-500 min-w-32 mt-2 md:mt-0"> {frontMatter.readingTime.text} {` • `} </p> </div> <div className="prose dark:prose-dark max-w-none w-full">{source}</div> </div> ) }

Styling with Tailwind CSS

Enhance the readability and visual appeal of your posts with the @tailwindcss/typography plugin. Incorporate it into your tailwind.config.ts to utilize the prose class, which provides elegant default styling for your markdown content.

tailwind.config.ts

const config: Config = {
  // rest of the props
  plugins: [
    require("@tailwindcss/typography"),
    // rest of the plugins
  ],
};
 
export default config

Conclusion:

By following these steps, you will integrate a sophisticated blog feature that leverages the power of MDX and Tailwind CSS in your Next.js website. This functionality will not only provide your users with valuable content but also enhance the overall user experience on your platform. Remember: Always test new features extensively and consider SEO best practices when designing your blog to ensure the best possible reach and user engagement.

With the foundational knowledge from this guide, you're well-equipped to take your Next.js site to the next level. Happy coding!