Why I write my articles by hand in MDX (and I don't regret it)
How I built the lyrad.dev blog with Fumadocs and MDX, without a database or CMS, and why this choice really simplifies life.

Daryl Ngako
If you want to create your own blog, you quickly find yourself faced with this question:
“Do I store my articles in a database, or do I do something else?”
Sanity, Contentful, Prisma + PostgreSQL/Supabase... there is no shortage of options. But I made a different choice for lyrad.dev. My articles live directly in the code, as MDX files. No dashboard, no database, no webhook to synchronize anything.
In this article, I will show you how I built my blog with Fumadocs, why I deliberately avoided a database, and what it actually changes on a daily basis.
What is Fumadocs?
Fumadocs is a documentation framework built on top of Next.js and MDX. Basically, it is designed for technical docs, the kind of site you see at open-source libraries. But it is flexible enough to serve as the basis for a blog.
Concretely, it provides you:
- A file-based routing system: each .mdx file automatically becomes a route
- A powerful MDX rendering engine with support for React components
- Integrated navigation, table of contents and search
- Simple configuration via source.config.ts
This is the equivalent of what Next.js does with app/ for routing, but applied to rich Markdown content.
Why no database?
That's the real question. And my answer is in one word: unnecessary complexity.
The “full stack for a blog” trap
When you think about storing articles in a database, you find yourself managing an entire infrastructure:
- A schema with a table posts, columns title, slug, content, published_at...
- An administration interface to create and edit articles
- An API or an ORM to query this database from the frontend
- Cache management so as not to repeat requests each time it is rendered
- And possibly a headless CMS if you want a clean UI
For a personal dev blog? It's too much. I will spend more time maintaining the plumbing than writing content.
What I wanted to avoid
With a CMS or a database, you introduce several points of friction:
- External dependency: if Sanity or Contentful changes its pricing or its API, your blog is impacted
- Network latency: each article requires a request to a third-party service during build or runtime
- Deployment complexity: environment variables, API tokens, migrations...
How does it work in practice
Here's how I put it all together step by step for this blog, from installation to rendering the pages.
1. Installation and dependencies (package.json)
We start by installing the heart of Fumadocs, its UI and the MDX manager. Installation of rendering components often goes through fumadocs-ui. In my package.json, here are the main dependencies that we must find:
pnpm add fumadocs-core fumadocs-ui fumadocs-mdxTo go further, I also added plugins like fumadocs-twoslash (for typed interactive code blocks) and fumadocs-docgen.
Then, it is crucial to update the scripts so that Fumadocs generates the data at the right time (via the fumadocs-mdx command):
"scripts": {
"dev": "fumadocs-mdx && next dev",
"build": "fumadocs-mdx && next build",
"postinstall": "fumadocs-mdx"
}2. TypeScript setup (tsconfig.json)
When launched, Fumadocs will read your files and generate a hidden folder .source which contains the typing and your compiled data. To help TypeScript navigate, we add this alias:
"compilerOptions": {
"paths": {
"fumadocs-mdx:collections/*": ["./.source/*"]
}
}3. Next.js integration (next.config.ts)
We wrap the Next.js configuration with createMDX so that the framework understands and compiles our local files correctly:
import type { NextConfig } from "next";
import { createMDX } from "fumadocs-mdx/next";
const nextConfig: NextConfig = {
reactStrictMode: true,
// Required for server-side tools such as Twoslash
serverExternalPackages: ["typescript", "twoslash", "oxc-transform"],
};
const withMDX = createMDX();
export default withMDX(nextConfig);4. Source Configuration (source.config.ts)
This is where we define the structure of our articles. With Zod, we validate the frontmatter (the article metadata). We also configure our Markdown plugins there.
import { defineConfig, defineDocs, frontmatterSchema } from "fumadocs-mdx/config";
import { z } from "zod";
// Define the article collection
export const docs = defineDocs({
dir: "src/content/blog", // 👈 The directory that contains the articles
docs: {
schema: frontmatterSchema.extend({
date: z.string(),
tags: z.array(z.string()).default([]),
featured: z.boolean().optional().default(false),
published: z.boolean().optional().default(true),
author: z.string(),
thumbnail: z.string(),
}),
},
});
// Configure the MDX plugins
export default defineConfig({
mdxOptions: {
// providerImportSource lets us inject custom interactive components
providerImportSource: "@/src/components/mdx/mdx-components",
},
});5. Creation of the loader (src/lib/source.ts)
Once the configuration is ready, we initialize the source object. This is the one we will call throughout the application to retrieve the list of articles or search for a specific article:
import { docs } from 'fumadocs-mdx:collections/server';
import { loader } from 'fumadocs-core/source';
export const source = loader({
baseUrl: '/blog',
source: docs.toFumadocsSource(),
});6. MDX Component Rendering (src/components/mdx/mdx-components.tsx)
One of the big advantages of MDX coupled with fumadocs-ui is the customization of the display. I map standard HTML tags to interactive React components.
For example, I add a zoom to my images and I customize the display of code blocks:
import defaultMdxComponents from "fumadocs-ui/mdx";
import { ImageZoom } from "fumadocs-ui/components/image-zoom";
import { CodeBlock as FumadocsCodeBlock, Pre } from "@/src/components/codeblock";
import type { MDXComponents } from "mdx/types";
export function getMDXComponents(components?: MDXComponents): MDXComponents {
return {
...defaultMdxComponents,
...components,
img: (props) => <ImageZoom className="w-full max-w-full" {...(props as any)} />,
pre: ({ ref: _ref, ...props }) => (
<FumadocsCodeBlock className="py-0" keepBackground {...props}>
<Pre>{props.children}</Pre>
</FumadocsCodeBlock>
),
// You can also inject domain components such as YouTube or Accordion
};
}7. Generating the article page (app/blog/posts/[slug]/page.tsx)
Finally, the Next.js page! We retrieve the content of the article with our source and render it statically. No database, everything is done at build time.
import { source } from "@/src/lib/source";
import { DocsBody } from "fumadocs-ui/page";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { TableOfContents } from "@/src/components/articles/table-of-contents";
interface PageProps {
params: Promise<{ slug: string }>;
}
// 1. Generate SEO metadata from the frontmatter
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const page = source.getPage([slug]);
if (!page) return { title: "Page Not Found" };
return {
title: page.data.title,
description: page.data.description,
keywords: page.data.tags,
openGraph: {
title: page.data.title,
description: page.data.description,
type: "article",
},
};
}
// 2. Render the page
export default async function PostPage({ params }: PageProps) {
const { slug } = await params;
// Fumadocs retrieves the article from its slug
const page = source.getPage([slug]);
if (!page) notFound();
const MDX = page.data.body;
return (
<main>
<h1>{page.data.title}</h1>
<DocsBody>
<MDX />
</DocsBody>
</main>
);
}The Fumadocs ecosystem
Before looking at how I structure my content files, it is good to remember that Fumadocs is divided into four main modular parts:
- Fumadocs Core: Handles the majority of logic under the hood (search, content adapters, Markdown extensions).
- Fumadocs UI: The default theme which offers a neat design for documentation sites and interactive components.
- Content Source: The source of your content. It can be a CMS, but here we use Fumadocs MDX (the official source for managing local data layers).
- Fumadocs CLI: A command line tool for installing UI components and automating tasks (very useful for customizing layouts).

File structure
With all of this setup in place, my content architecture is 100% file-based.
Each .mdx file contains a YAML frontmatter at the top of the file, which must respect the Zod schema defined earlier:
---
title: "Why I write my articles by hand in MDX..."
description: "How I built lyrad.dev with Fumadocs..."
date: "2025-06-01"
tags: ["Next.js", "tutorial"]
thumbnail: "/thumbnails/articles/fumadocs-pour-un-blog.png"
author: "daryl"
---
Article content goes here...Write an article
My workflow for publishing a new article is super simple:
- I create a file .mdx in src/content/blog/
- I write my article in my IDE with autocompletion (thanks to typed frontmatter)
- I make a git push
- Vercel detects the commit, rebuilds and redeploys
That's all. No interface to open, no database to query.
The real advantages
Maximum performance
All content is generated statically. Pages are pre-rendered HTML files, served directly from the Vercel CDN. Zero latency, zero runtime requests.
The blog lives in the code
All my articles are versioned with Git. If I make a mistake, git revert. If I want to see the history of an item, git log. This is a level of traceability that you don't have with a CMS.
Zero additional cost
No Sanity subscription, no Contentful paid plan, no Prisma or Supabase instance to maintain. The blog runs on Vercel in free tier.
Total freedom on rendering
Because it's MDX, I can inject any React component directly into an article:
## Voici un composant custom
<MonComposantInteractif data={mesData} />What it does not replace
I'll be honest: this approach has its limits.
If you want to let non-developers write content, MDX files in a Git repo, this is not the right solution especially since you have to rebuild with each push.
But for a dev blog that I manage alone? The MDX file in the IDE is unbeatable.
Conclusion
I could have connected a database, connected a CMS, written my articles in a beautiful interface with a rich editor. I would also have spent hours setting this up before writing my first word.
Instead, I have a file .mdx, my IDE, and git push. In a few seconds, the article is online.
Fumadocs allowed me to keep all the power of Next.js and MDX without additional complexity. The blog remains simple, fast, maintainable.
If you want to launch a dev blog without having to worry about infrastructure, this is a stack to seriously explore.
