Introduction
I think many developers, like me, have a thought lingering in the back of their minds: "I have a portfolio site. Why not write my articles there too?"
After that thought, two approaches typically come to mind:
- Creating a separate page for each article manually.
- Entering articles through a CMS (Content Management System).
Let's first talk about why we didn't go with the first idea — creating a separate page for every article. In short: we think it would take too much time. And honestly, we're right about that.
I'll be using React for the examples in this article.
- Create a component called
new-article.tsx. - Add
new-article.tsxto the route tree. - Add a link to
new-article.tsxwherever it needs to be accessed (navbar, article list, etc.).
It might look like just 3 steps, but consider doing this on a weekly basis if you're regularly publishing articles on Medium.
Maybe it's not actually that long of a process — maybe I'm just lazy. I can't judge that one.
Now let's talk about why we didn't go with the second idea — entering articles through a CMS. Honestly, this approach makes a lot of sense in the long term, but has a high upfront cost.
For a CMS, you need a database to store the data and a backend to manage access to that database. Any developer who can't build a backend project or manage a database is already out of the picture.
Let's say we've handled the backend and database management — we still need to properly store attachments within articles (images, videos, etc.) on a server or cloud service.
So our problem boils down to this: "OK, neither creating individual pages in the short term nor pulling articles from a CMS is practical. So how do we do what we want?"
We could publish on Medium, which is where you're reading this article right now. While it's not a complete solution for us, it's widely accepted.
By adding MDX support to our portfolio site, we can write articles in MD (Markdown) format and publish them on the same layout however we like. This way, we can make our portfolio site — which usually just sits there with About Me, Experience, Contact Info, Projects — a bit more dynamic.
Let's not overlook the fact that this MDX-based article publishing system will also drive traffic to our portfolio.
What Is MDX?
MDX is a format born from combining two different structures:
MD (Markdown) + JSX/TSX (React components) = MDX
In short, unlike regular Markdown, it allows us to use JSX/TSX (React components) inside our content.
You can learn more by doing your own research. No need to elaborate further in this article.
Before we begin, it's worth noting again that I'll be building this with Vite/React.
Required Packages
I prefer using Bun for package management. The choice of package manager depends entirely on your preference and your project's setup.
If you're using npm, you install new packages with
npm install. If you're using yarn, pnpm, or Bun like me, you useyarn|pnpm|bun add.
bun add @mdx-js/react @mdx-js/rollup
bun add -D remark-frontmatter remark-mdx-frontmatter reading-time
Let's explain what each package does:
@mdx-js/react: Enables MDX to work within the React ecosystem in an integrated way.
@mdx-js/rollup: Since MDX files can't run directly in the browser, this handles the MDX → JS transformation. To be clear — this happens at build time. In short, this package handles the bundler integration.
remark-frontmatter: In blog posts, we typically write something like this:
---
title: 'RHF + Zod'
date: '2026-02-15'
tags: ['react', 'zod']
---
This section is called frontmatter. The Markdown parser treats it as regular text. remark-frontmatter recognizes the --- blocks and adds them as special nodes to the tree. In short, it adds metadata support to Markdown.
remark-mdx-frontmatter: Just parsing frontmatter isn't enough. We also want to use this data on the JSX/TSX side. For example:
---
title: 'RHF + Zod'
---
becomes:
export const frontmatter = {
title: 'RHF + Zod',
};
This means we can do:
import { frontmatter } from './example.mdx';
console.log(frontmatter.title);
In summary, it converts frontmatter into an exported object.
reading-time: This isn't strictly necessary for our overall architecture. This package calculates the estimated reading time of an article.
We've installed the packages and now know what they do. Next up is the integration process.
Vite Configuration
vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import mdx from '@mdx-js/rollup';
import remarkFrontmatter from 'remark-frontmatter';
import remarkMdxFrontmatter from 'remark-mdx-frontmatter';
export default defineConfig({
plugins: [
react(),
mdx({
remarkPlugins: [remarkFrontmatter, [remarkMdxFrontmatter, { name: 'frontmatter' }]],
}),
],
});
MDX Type Definition (Required for TypeScript)
src/types/mdx.ts
export interface ArticleFrontmatter {
title: string;
date: string;
description: string;
tags: string[];
}
export interface ArticleModule {
default: React.ComponentType;
frontmatter: ArticleFrontmatter;
}
Reading Time Helper
If you didn't install the reading-time package, you can skip this step.
src/lib/readingTime.ts
import readingTime from 'reading-time';
export function calculateReadingTime(content: string) {
return Math.ceil(readingTime(content).minutes);
}
Typed Article Collection System
src/lib/articles.ts
import { calculateReadingTime } from './readingTime';
import type { ArticleFrontmatter, ArticleModule } from '@/types/mdx';
const rawModules = import.meta.glob('../articles/*.mdx', {
eager: true,
as: 'raw',
});
const componentModules = import.meta.glob('../articles/*.mdx', {
eager: true,
}) as Record<string, ArticleModule>;
export interface Article {
slug: string;
frontmatter: ArticleFrontmatter;
readingTime: number;
Component: React.ComponentType;
}
export const articleMap: Record<string, Article> = {};
Object.keys(componentModules).forEach(path => {
const slug = path.split('/').pop()!.replace('.mdx', '');
const mod = componentModules[path];
const raw = rawModules[path] as string;
articleMap[slug] = {
slug,
frontmatter: mod.frontmatter,
readingTime: calculateReadingTime(raw),
Component: mod.default,
};
});
export const articles = Object.values(articleMap);
What this helper achieves:
- MDX files are loaded both as components and as raw strings. (You might wonder: "We were already loading them as components — why also as raw strings?" Because the reading-time package accepts string input. So we load raw strings solely for reading time calculation.)
- TypeScript enforces frontmatter. The metadata we write in MDX files (title, date, tags, etc.) is checked by TypeScript at compile time.
Router
src/router.tsx
import { createBrowserRouter } from 'react-router';
import ArticlePage from './pages/ArticlePage';
export const router = createBrowserRouter([
{
path: '/articles/:slug',
element: <ArticlePage />,
},
]);
Article Page
src/pages/ArticlePage.tsx
import { useParams } from 'react-router';
import { articleMap } from '@/lib/articles';
export default function ArticlePage() {
const { slug } = useParams();
const article = slug ? articleMap[slug] : null;
if (!article) return <div>Article not found</div>;
const { Component, frontmatter, readingTime } = article;
return (
<div className="max-w-3xl mx-auto py-10">
<h1 className="text-4xl font-bold">{frontmatter.title}</h1>
<p className="text-gray-500">
{frontmatter.date} · {readingTime} min read
</p>
<Component />
</div>
);
}
After this setup, all .mdx files inside the src/articles directory will be loaded into the article system we built. Since we're also feeding this into react-router, every article you write becomes accessible immediately.
Bonus: Converting Medium Articles
You can quickly convert articles you've previously published on Medium into the MDX format and republish them on your own portfolio or blog. There's also a method where you can first publish on Medium (for easier text formatting) and then republish on your own site.
All you need is the medium-to-markdown package by David Tesler. You can find usage instructions in the repo's README.
We can't say it ends here — features like sitemap generation and RSS feed generation can also be added to this architecture, but I'll wrap up the article here.
Thank you to everyone who read this far. See you in the next article.
