Building a blog with App Router, React Server Components and Tailwind

Published on 2024-03-28

As 2024 started I finally got around to working on the blog portion of this site. While I was having a lot of fun building out the other pages (see Building a new website with Next.js 14 and App Router) I knew that I needed to get to work on the blog portion so that I could write about all the work I had been doing! Thankfully I wasn't starting this endeavour from scratch as @max_leiter, who works at Vercel had written an extremely thorough tutorial on how to do this. I encourage you to read Building a blog with Next.js 14 and React Server Components as he goes into far more detail on all the steps needed to start from scratch. I didn't want to recreate Max's blog post so what I'm presenting here are some of the changes I made.

Tailwind#

I've been building my site using shadcn/ui, which leverages Tailwind so I figured I would stick with this approach when building out the blog portion as well. I discovered that Tailwind has a typography plugin which adds the prose class.

The @tailwindcss/typography plugin adds a set of prose classes that can be used to quickly add sensible typographic styles to content blocks that come from sources like markdown or a CMS database.

This is exactly what I wanted and using Route Groups allowed me to wrap all the content in the blog and any other page I want to render with Markdown in the prose class.

You can look at my layout.tsx but the contents are shown below.

import { ReactNode } from "react";

export default function ContentLayout({ children }: { children: ReactNode }) {
return (
<div className="md:w-full md:flex md:justify-center">
<div className="prose dark:prose-invert">{children}</div>
</div>
);
}
import { ReactNode } from "react";

export default function ContentLayout({ children }: { children: ReactNode }) {
return (
<div className="md:w-full md:flex md:justify-center">
<div className="prose dark:prose-invert">{children}</div>
</div>
);
}

There is functionality for dark mode as well with dark:prose-invert! With this layout in place I was extremely happy to not worry about anymore styling for any of my content pages.

No next-mdx-remote#

In Max's Fetching and rendering markdown section of his blog, he mentions that he wants to use next-mdx-remote for niche specific reasons. I don't have any reasoning to do that, so I wanted to change the approach and ensure that all my Markdown files are rendered the same way. What I mean by that is a regular .mdx file can be rendered as a page component and the Getting Started section of Markdown and MDX documentation for Next.js shows how this is done. Instead of passing the plugins and the contents of the mdx-components.tsx file to next-mdx-remote, I want to leverage the createMdx functionality described in the Getting Started tutorial above. I'll cover my changes to createMdx and mdx-components.tsx in the next section.

My fetchPosts.ts differs a bit from how Max set his up. Lets go through the changes that allowed me to stop using next-mdx-remote. First up is parseMdxFiles.

async function parseMdxFiles() {
const filePaths = await fs.readdir("./posts/");

const postsData = [];

for (const filePath of filePaths) {
const postFilePath = `./posts/${filePath}`;
const postContent = await fs.readFile(postFilePath, "utf8");
const { data } = matter(postContent);

if (!data.draft) {
const postData = { ...data, content: postContent } as Post;
postsData.push(postData);
}
}

return postsData;
}

const parsedMdxFiles = cache(parseMdxFiles);
async function parseMdxFiles() {
const filePaths = await fs.readdir("./posts/");

const postsData = [];

for (const filePath of filePaths) {
const postFilePath = `./posts/${filePath}`;
const postContent = await fs.readFile(postFilePath, "utf8");
const { data } = matter(postContent);

if (!data.draft) {
const postData = { ...data, content: postContent } as Post;
postsData.push(postData);
}
}

return postsData;
}

const parsedMdxFiles = cache(parseMdxFiles);

In parseMdxFiles we're reading the checked in blog posts at ./posts and constructing an array of Post objects. Those objects contain the frontmatter, which is extracted using gray-matter and the actual post content. We're also using the new cache feature from React create parsedMdxFiles to ensure that we only ever have to parse these files once, which is what we want because we're going to reference them a few more times.

The next function is postComponents.

export async function postComponents() {
const components: Record<string, () => ReactElement> = {};

const postsData = await parsedMdxFiles();

for (const post of postsData) {
const { default: Component } = await import(
`@/posts/${post.date}-${post.slug}.mdx`
);
components[post.slug] = Component;
}

return components;
}
export async function postComponents() {
const components: Record<string, () => ReactElement> = {};

const postsData = await parsedMdxFiles();

for (const post of postsData) {
const { default: Component } = await import(
`@/posts/${post.date}-${post.slug}.mdx`
);
components[post.slug] = Component;
}

return components;
}

Here we're using our cached parsedMdxFiles and building up a hash of React components, which are the rendered content of each Markdown file. Since we're in the world of React Server Components, we don't need to do any sort of Lazy Loading as the docs state

Lazy loading applies to Client Components.

Instead, we can use await import as a way to dynamically load each component. It took me a long time to come to this conclusion and I nearly gave up; I was going to manually import each blog post after trying to use React.lazy and next/dynamic to achieve this, but I was happy to finally come to the realization I can just await import. We can then use this function in slug/page.tsx as so.

export default async function BlogPost({
params: { slug },
}: BlogPostPageParams) {
const components = await postComponents();
const postComponent = components[slug];

if (!postComponent) return notFound();
return postComponent();
}
export default async function BlogPost({
params: { slug },
}: BlogPostPageParams) {
const components = await postComponents();
const postComponent = components[slug];

if (!postComponent) return notFound();
return postComponent();
}

With that, I was able to get the blog posts rendering without the use of next-mdx-remote.

My createMDX and mdx-components.tsx Setup#

Now that the blog posts are rendering, I wanted to add some rehype plugins to give a little extra functionality to each post. I added rehype-slug and rehype-autolink-headings so that I could create linkable header elements. In the end my createMdx in next.config.mjs function looked like this

const withMDX = createMDX({
options: {
remarkPlugins: [
remarkFrontmatter,
[remarkMdxFrontmatter, { name: "frontmatter" }],
],
rehypePlugins: [rehypeSlug, rehypeAutolinkHeadings],
},
});
const withMDX = createMDX({
options: {
remarkPlugins: [
remarkFrontmatter,
[remarkMdxFrontmatter, { name: "frontmatter" }],
],
rehypePlugins: [rehypeSlug, rehypeAutolinkHeadings],
},
});

The next step was to create a nice clickable header that would be rendered. I decided <h2> would be where I would start with this and if I wanted to add more clickable elements I could expand later. I created an H2WithAnchor component that looks like this.

import { PropsWithChildren } from "react";

type Props = {
id?: string;
} & PropsWithChildren;
export function H2WithAnchor({ id, children }: Props) {
return (
<h2 className="group" id={id}>
{children}
<a
aria-label={id}
href={`#${id}`}
className="p-2 italic font-bold opacity-0 group-hover:opacity-75"
>
#
</a>
</h2>
);
}
import { PropsWithChildren } from "react";

type Props = {
id?: string;
} & PropsWithChildren;
export function H2WithAnchor({ id, children }: Props) {
return (
<h2 className="group" id={id}>
{children}
<a
aria-label={id}
href={`#${id}`}
className="p-2 italic font-bold opacity-0 group-hover:opacity-75"
>
#
</a>
</h2>
);
}

Then I added it to my mdx-components.tsx.

export const mdxComponents: MDXComponents = {
// ...
h2: ({ id, children }) => <H2WithAnchor id={id}>{children}</H2WithAnchor>,
// ...
};
export const mdxComponents: MDXComponents = {
// ...
h2: ({ id, children }) => <H2WithAnchor id={id}>{children}</H2WithAnchor>,
// ...
};

Now there is a nice # that appears when hovering over the <h2> elements and will change the route to be anchored on the heading. You can click on building-a-blog-with-app-router-rsc-tailwind#my-createmdx-and-mdx-componentstsx-setup to land right back here!

I've added a handful of other components into the mdx-components.tsx file and am really liking the ability to mix React into my preferred way of writing.

Nitpicking Bright's Code Highlighting#

Part of Max's tutorial was choosing bright as the code highlighting solution. It was extremely easy to set up and with a variety of different themes, I was able to find one that I really liked and matched the color scheme on my site pretty closely. I chose solarized, which I found from the Code Hike Themes page.

One of my favorite things to do when writing Markdown files is to use the backticks to highlight inline code. However, I noticed that the current implementation was not actually applying any styling and I was ending up with words that looked like `this`. I looked at the HTML that was being rendered and it was a <code> tag so I figured I could update my mdx-components.tsx file to have something like

code: ({ children }) => <div className="bg-blue-50">{children}</div>;
code: ({ children }) => <div className="bg-blue-50">{children}</div>;

and see the changes. Instead I was presented with this nice error message

Unhandled Runtime Error
Error: Cannot read properties of undefined (reading 'children')

Call Stack
children
node_modules/bright/dist/index.mjs (516:44)
Array.map
<anonymous>
map
node_modules/bright/dist/index.mjs (512:54)
Unhandled Runtime Error
Error: Cannot read properties of undefined (reading 'children')

Call Stack
children
node_modules/bright/dist/index.mjs (516:44)
Array.map
<anonymous>
map
node_modules/bright/dist/index.mjs (512:54)

I did some investigation and eventually opened a ticket Attempting to adjust 'code' in mdx-components breaks. I followed up two days later with a re-creation and potential solution to the issue. Unfortunately minutes after posting that comment my house was struck with a weather related catastrophe and I completely forgot about attempting to create a PR to remedy the issue. Much to my surprise, a few months later @joshwcomeau, whom I follow on Twitter, opened a PR referencing the issue I created. He was experiencing the same thing and wanted to be able to utilize adding styling for his React course The Joy of React (you should check it out, he'll teach you to build a blog as well!) The maintainer of the project, @pomber, came in, tidied up the code a bit more, and we had ourselves a new 0.8.5 release of bright!

Needless to say, my original code changes I detailed in the issue weren't anywhere close to what was merged, I was overjoyed to be a part of open source collaboration. Having multiple people are able to come together to improve software that we all enjoy using is an awesome experience. Now I can highlight as much as I want to!

Conclusion#

I learned a lot while putting all this together and I'm really happy with the results. For now, I've got everything in a place where I won't need to spend any more time working on setting up the blog and I can focus on writing more content!