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

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

Sedat Can Uygur

22 Mar 2025

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:flex md:w-full 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:flex md:w-full 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 is all rendered the same way. What I mean by that is a regular .mdx file can be rendered as a page component, the Getting Started section of Markdown and MDX documentation for Next.js shows how this is done. Since I enjoy writing content in Markdown and I have other pages on this site, such as About which are written in Markdown, I wanted the blog posts as well as those pages to all be rendered using the mdx-components.tsx. Given all that, let's dive into some changes.

My fetchPosts.ts differs a bit from how Max set his up so we'll 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 which 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 are going to build 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 and 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 this realization. We can then use this function in slug/page.tsx as so.

export default async function BlogPost({
params: { slug },
}: BlogPostPageParams) {
const post = await fetchPost(slug);

if (!post) return notFound();

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

if (!post) return notFound();

const components = await postComponents();
return components[slug]();
}

With that, I was able to get the blog posts rendering without the use of next-mdx-remote.If you have any questions about this approach or if I did something that is completely crazy, feel free to reach out on the Contact page because I would love to discuss this more!

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 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. The speed at which the blog posts load is crazy fast so there is no waiting around to get to reading.