How I Built My Portfolio Blog with Next.js and Fumadocs
On this page+
When I first added writing to this portfolio, I deliberately kept the implementation small. Blog posts lived as local MDX files, a helper read them from disk, and a dynamic Next.js route compiled each article. It was a useful starting point and gave me complete control over the design.
As the posts became longer and more visual, that small pipeline started accumulating responsibilities: frontmatter parsing, content discovery, syntax highlighting, table styling, reading time, metadata, RSS, navigation, and eventually support for diagrams and video.
The blog still uses MDX, but it no longer uses the traditional hand-rolled fs + gray-matter + runtime MDX approach described in the first version of this article. Today, Fumadocs MDX powers the content layer, while the surrounding interface remains part of my custom portfolio design.
What the Blog Uses Today
The current stack is intentionally split into two layers:
| Responsibility | Technology |
|---|---|
| Application and routes | Next.js 16 App Router |
| Content compilation | Fumadocs MDX |
| Content source and navigation data | Fumadocs Core |
| Article format | Local MDX files |
| Styling | Tailwind CSS Typography + custom components |
| Code highlighting | Shiki through the Fumadocs MDX pipeline |
| Page transitions | Framer Motion + View Transitions |
| Theme support | next-themes |
| Reading time | reading-time |
Fumadocs is being used as a headless content engine here. I did not replace the portfolio with a documentation template. The navbar, footer, clay color palette, animations, article cards, share controls, and previous/next navigation are still custom.
Why I Moved Away from the Original Pipeline
The first implementation looked roughly like this:
MDX file
↓
Node.js fs reads the file
↓
gray-matter parses frontmatter
↓
next-mdx-remote-client compiles the body
↓
Custom React components render the articleThat architecture is perfectly reasonable for a small blog. The problem was not MDX itself; the problem was the amount of publishing infrastructure I was rebuilding around it.
I wanted the next version to provide:
- Schema-validated frontmatter
- One content source for pages, RSS, sitemap, metadata, and Open Graph images
- Generated table-of-contents data
- Build-time MDX compilation
- Reliable heading identifiers and anchor navigation
- Reusable media components for diagrams, videos, and technical metrics
- A safer foundation for much longer engineering articles
Fumadocs provided those capabilities without forcing me to give up the portfolio's visual identity.
Defining a Typed Content Collection
Every post now belongs to a Fumadocs collection. Its frontmatter is checked against a schema during development and production builds:
export const blog = defineDocs({
dir: "src/content/blog",
docs: {
schema: z.object({
title: z.string().min(1),
description: z.string().min(1),
date: z.iso.date(),
updated: z.iso.date().optional(),
tags: z.array(z.string()).default([]),
published: z.boolean().default(true),
}),
},
});This turns frontmatter from an informal convention into a contract. A missing description or invalid date is discovered during a build rather than after publishing.
One Source for Every Blog Feature
The generated collection is passed into the Fumadocs source loader:
export const blogSource = loader({
baseUrl: "/blog",
source: blog.toFumadocsSource(),
});The same source now drives:
- The main blog index
- Recent posts on the homepage
- Static article routes
- Previous and next article navigation
- Page metadata
- Dynamic Open Graph images
- Individual sitemap entries
- The RSS feed
Previously, the RSS file was written as a side effect while rendering the blog page. It now has a dedicated /rss.xml route that reads the same published-post collection as everything else.
Rendering Compiled MDX
Fumadocs compiles the article and exposes it as a React component. The article route only needs to find the page and render its body:
const post = await getPostBySlug(slug);
if (!post) notFound();
const Content = post.body;
return (
<BlogDetail post={post} toc={post.toc}>
<Content components={mdxComponents} />
</BlogDetail>
);Compilation, heading extraction, syntax highlighting, and structured content data are handled before the article reaches the page component.
Keeping the Design Custom
The biggest migration requirement was visual continuity. I wanted the stronger content foundation, not a generic documentation layout.
The custom MDX component map still controls:
- Heading spacing and hierarchy
- External links
- Responsive tables
- Blockquotes and callouts
- Light and dark code-block themes
- Copy buttons
- Full-width images and diagrams
- Video embeds with poster frames and captions
- Technical metric cards
That means an ordinary Markdown table can become horizontally scrollable on a phone, while a Graph RAG architecture diagram can be displayed as a captioned, full-width figure.
A Generated, Responsive Table of Contents
Fumadocs extracts the heading tree from every post. The article shell uses that data to render:
- A sticky "On this page" sidebar on desktop
- A compact expandable section on mobile
- Indentation for nested headings
- Active-section highlighting while scrolling
- Native anchor links that work without JavaScript
This matters for long technical posts. Readers can understand the structure immediately and jump directly to the part they need.
Code Blocks in Light and Dark Themes
Code highlighting uses separate Shiki themes:
rehypeCodeOptions: {
themes: {
light: "github-light",
dark: "catppuccin-mocha",
},
}The surrounding code-block component adds consistent spacing, horizontal scrolling, rounded borders, and a copy action. Theme-aware token variables keep the light theme genuinely light instead of placing every code example inside a dark rectangle.
Preparing for Visual Engineering Articles
The migration also introduced small MDX primitives for content that plain Markdown does not express well:
<Video
src="/blog/example/demo.webm"
poster="/blog/example/poster.webp"
caption="A short walkthrough of the finished workflow."
/>
<Figure
src="/blog/example/architecture.png"
alt="System architecture"
caption="How data moves through the production system."
/>
<Metric value="<3s" label="Typical response time" />Videos use native browser controls, inline playback, lazy metadata loading, and optional poster images. Figures include meaningful alternative text and captions. These components let an article remain readable in MDX while producing a polished result in the browser.
Static Generation and SEO
Each published article is generated as a static page. Fumadocs supplies the available slugs, and Next.js pre-renders them during the production build.
Every article receives:
- Title and description metadata
- A dynamic Open Graph image
- A canonical, crawlable URL
- An entry in
sitemap.xml - An entry in the RSS feed
Unknown slugs now return a proper not-found page instead of allowing a filesystem read to throw unexpectedly.
What I Learned from the Migration
The original implementation was not a mistake. It was the right amount of infrastructure when the blog was small, and building it taught me how the MDX pipeline fits together.
The useful lesson is knowing when a simple internal abstraction has started becoming a content platform of its own. At that point, adopting a focused tool creates more room to work on the actual reading experience.
Fumadocs now owns content compilation, validation, and structured page data. Next.js owns routing, metadata, and static generation. The portfolio's components own presentation. Those boundaries are much easier to extend than the original all-in-one loader.
Conclusion
The blog remains local, version-controlled, and MDX-based, but its publishing system is now powered by Fumadocs rather than a traditional runtime MDX pipeline.
That gives me a typed and scalable content foundation without losing the custom design that makes the blog feel like part of my portfolio. More importantly, it is ready for the kind of article I want to publish next: deep engineering write-ups with architecture diagrams, evaluation results, responsive videos, and enough structure to remain readable from beginning to end.