Rendering Markdown in React Server Components
For this blog, I wanted a straightforward way to write posts, include code examples, and publish without maintaining a separate content service. I settled on MDX files in the repository, rendered with React Server Components.
The post routes are set up to prerender during production builds. Visitors get the rendered content without downloading a Markdown parser or syntax highlighter.
Why I keep posts in the repo
I’m the only person writing here, and publishing through Git fits how I already work. Each post is a file I can edit, review, and version alongside the code.
A CMS would make sense if I needed an editing interface for other authors or wanted to publish independently of a deployment. For now, files are enough. The tradeoff is simple: changing a published post means rebuilding the site.
The rendering pipeline
Three libraries handle the content:
gray-matterseparates YAML frontmatter from the MDX body.next-mdx-remote/rsccompiles and renders that body as React content on the server.rehype-pretty-codeuses Shiki to highlight fenced code blocks during compilation.
In this setup, that processing happens while the posts are prerendered. The browser receives the resulting markup and styles, rather than running those libraries itself.
That doesn’t mean the page has zero client JavaScript. The site still has interactive navigation, a theme toggle, and framework code. The narrower—and more useful—claim is that Markdown parsing and syntax highlighting don’t add a browser-side runtime.
I only compile MDX checked into my own repository. MDX can contain JSX and expressions, so I wouldn’t treat arbitrary user submissions as ordinary text and pass them through this same pipeline.
Reading the content
The content layer reads each file, validates its frontmatter, filters out drafts in production, and sorts the remaining posts by date. Here’s the core of that function, with imports and the missing-directory handling omitted:
export const getAllPosts = cache(async (): Promise<Post[]> => {
const filenames = await fs.readdir(POSTS_DIR);
const posts = await Promise.all(
filenames
.filter((name) => name.endsWith(".mdx"))
.map(async (name) => {
const raw = await fs.readFile(path.join(POSTS_DIR, name), "utf8");
return parsePost(name, raw);
}),
);
return posts
.filter((post) => !post.draft || process.env.NODE_ENV !== "production")
.sort((a, b) => b.date.localeCompare(a.date));
});React’s cache() can reuse the result when the same function is called within a
shared server rendering context. It isn’t a persistent content cache, and it doesn’t
guarantee one filesystem read across an entire build. Different prerendered pages and
build phases can still perform their own reads.
The React documentation on cache
explains its scope and why it shouldn’t be confused with caching between requests.
Prerendering the post routes
Each filename becomes a post slug. The dynamic post route supplies those slugs to Next.js and disables on-demand generation for unknown ones:
export const dynamicParams = false;
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}With this route’s current configuration—no request-dependent data or revalidation—the
listed posts are prerendered during next build. A slug outside that list returns a
404. Publishing another post requires a new build.
generateStaticParams supplies the paths; it isn’t a blanket guarantee that any page
using it will stay entirely static. Adding request-dependent behavior changes the
rendering requirements. The Next.js documentation
covers how the function works with route configuration.
Reading files at request time is also possible. On a deployment that bundles server code, those files need to be included in the bundle. Next.js provides output file tracing configuration for that. Prerendering is the approach I chose so serving a published post doesn’t need a runtime read of its MDX source.
Keeping publication dates consistent
Some YAML parsers turn an unquoted date: 2026-09-05 into a JavaScript
Date at UTC midnight. I use YAML 1.2’s core schema through a custom gray-matter
parser so both quoted and unquoted dates stay strings. The content layer then checks
that each value is a real calendar date in YYYY-MM-DD format.
Formatting a Date without an explicit timezone uses the environment’s default. In
a Server Component, that’s the server’s timezone; in browser code, it’s the visitor’s.
UTC midnight on September 5 is still September 4 in UTC-6.
Keeping the value as a string is useful, but it doesn’t solve that problem if I later
convert it back to a Date. I also specify UTC when formatting it:
function formatDate(date: string): string {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
}).format(new Date(`${date}T00:00:00Z`));
}That keeps the displayed calendar date consistent across environments. These values represent publication days, so I don’t want them adjusted to a reader’s local time.
The tradeoff
This setup keeps writing close to the code and makes publishing part of the existing deployment workflow. I can preview drafts locally, review changes in Git, and let the build generate the post pages, listing, sitemap, and RSS feed.
It also means waiting for a deployment whenever I publish or correct something. That’s a reasonable tradeoff for this blog. If the publishing workflow changes, I can revisit where the content lives without giving up server-side rendering.