— 10 min read

Building a modern web blog involves more than just writing content—it's about creating a fast, maintainable, and scalable platform that provides an excellent developer and user experience. In this post, I'll walk you through the journey of building this blog, from initial concept to production deployment.
When I started this project, I had several goals in mind:
// The foundation of our modern blog
const techStack = {
framework: "React Router v7", // Latest version with excellent DX
language: "TypeScript", // Type safety and better developer experience
styling: "Tailwind CSS v4", // Utility-first CSS with dark mode
content: "MDX", // Markdown with React components
deployment: "Fly.io", // Global edge deployment
packageManager: "Bun", // Fast JavaScript runtime and package manager
};React Router v7: The latest version brings significant improvements in performance and developer experience. The new data APIs and improved caching make it perfect for a content-heavy site.
TypeScript: Essential for maintaining code quality in a growing codebase. The type safety prevents bugs and improves developer productivity.
Tailwind CSS v4: The latest version with improved performance and better dark mode support. The utility-first approach makes styling consistent and maintainable.
MDX: Allows us to embed React components directly in markdown, enabling rich, interactive content.
One of the most interesting aspects of this blog is how we handle content. Instead of a traditional CMS, we use GitHub as our content management system:
// Content is fetched directly from GitHub API
async function fetchMdxFromGitHub(path: string) {
const url = `${GITHUB_API_BASE}/repos/${GITHUB_OWNER}/${GITHUB_REPO}/contents/${path}?ref=${GITHUB_BRANCH}`;
const headers: Record<string, string> = {};
if (GITHUB_TOKEN) headers["Authorization"] = `Bearer ${GITHUB_TOKEN}`;
const res = await fetch(url, { headers });
if (!res.ok)
throw new Error(`Failed to fetch MDX from GitHub: ${res.statusText}`);
return await res.json();
}This approach provides several benefits:
Images are also managed through GitHub, with automatic URL rewriting:
export async function getImageUrl(slug: string, imageName: string) {
if (!imageName) return "";
try {
const imagePath = path.posix.join("content/posts", slug, imageName);
const { data: fileData, error } = await tryCatch(
fetchFileFromGitHub(imagePath),
);
if (error || !fileData || !fileData.download_url) {
return "";
}
return fileData.download_url;
} catch (error) {
return "";
}
}For code blocks, we use rehype-pretty-code with custom styling:
export async function compileMDX(content: string): Promise<string> {
const compiled = await compile(content, {
outputFormat: "function-body",
development: false,
rehypePlugins: [
[
rehypePrettyCode,
{
theme: "github-dark",
keepBackground: true,
onVisitLine(node: any) {
if (node.children.length === 0) {
node.children = [{ type: "text", value: " " }];
}
},
onVisitHighlightedLine(node: any) {
node.properties.className.push("highlighted");
},
onVisitHighlightedWord(node: any) {
node.properties.className = ["word"];
},
},
],
],
});
return String(compiled);
}The blog implements infinite scroll for better user experience:
const loadMore = useCallback(async () => {
if (loading || page * pageSize >= total) return;
setLoading(true);
void fetcher.load(`/writing?page=${page + 1}&pageSize=${pageSize}`);
}, [loading, page, pageSize, total, fetcher]);We use our own tryCatch wrapper for robust error handling:
import { tryCatch } from "@herrlich-digital/trycatch-wrapper";
export const loader = async ({ request }: { request: Request }) => {
const { data, error } = await tryCatch(getPosts(page, pageSize));
if (error) {
console.error("Error loading posts:", error);
throw new Response("Posts not found!", { status: 404 });
}
return data;
};The blog features seamless dark mode with system preference detection:
const noFlashScript = `
(function() {
function applyTheme(themeValue) {
const cl = document.documentElement.classList;
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
cl.remove('light', 'dark');
if (themeValue === 'dark' || (themeValue === 'system' && prefersDark)) {
cl.add('dark');
}
}
let theme = 'system';
try {
const storedTheme = localStorage.getItem('theme');
if (storedTheme && ['light', 'dark', 'system'].includes(storedTheme)) {
theme = storedTheme;
}
} catch (e) {
console.warn('Could not access localStorage for theme preference.');
}
applyTheme(theme);
})();
`;Automatic RSS feed generation for content syndication:
const feed = new Feed({
title: "Herrlich Digital",
description: "All Writings from Herrlich Digital",
id: siteUrl,
link: siteUrl,
language: "en",
favicon: `${siteUrl}/favicon-dark.svg`,
copyright: `All rights reserved ${new Date().getFullYear()}, Christoph Planken`,
updated: posts[0] ? new Date(posts[0].date) : new Date(),
feedLinks: {
rss2: `${siteUrl}/rss.xml`,
atom: `${siteUrl}/atom.xml`,
json: `${siteUrl}/feed.json`,
},
author: {
name: "Christoph Planken",
link: siteUrl,
},
});One of the biggest challenges was implementing runtime MDX compilation. Since content is fetched from GitHub, we needed to compile MDX at runtime:
export default function Writing() {
const { post, readingTime, compiledMDX } = useLoaderData<typeof loader>();
const [MDXContent, setMDXContent] = useState<React.ComponentType | null>(null);
useEffect(() => {
void (async () => {
const { default: Content } = await run(compiledMDX, { ...runtime });
setMDXContent(() => Content);
})();
}, [compiledMDX]);
if (!MDXContent) return <Spinner />;
return (
<div className="prose dark:prose-invert max-w-none flex-1 px-[10vw] py-10 md:px-[20vw]">
<Title date={post.date} readingTime={readingTime || 0} image={post.image}>
{post.title}
</Title>
<MDXProvider>
<MDXContent />
</MDXProvider>
</div>
);
}GitHub's API has rate limits, so we implemented proper error handling and caching:
const headers: Record<string, string> = {};
if (GITHUB_TOKEN) headers["Authorization"] = `Bearer ${GITHUB_TOKEN}`;
const res = await fetch(url, { headers });
if (!res.ok)
throw new Error(`Failed to fetch MDX from GitHub: ${res.statusText}`);Images are served directly from GitHub's CDN, but we needed to handle cases where images don't exist:
export async function rewriteImageUrls(
content: string,
slug: string,
): Promise<string> {
const matches = [...content.matchAll(imageRegex)];
let rewrittenContent = content;
for (const match of matches) {
const [fullMatch, alt, imagePath] = match;
if (!imagePath.startsWith("http")) {
const downloadUrl = await getImageUrl(slug, imagePath);
if (downloadUrl) {
rewrittenContent = rewrittenContent.replace(
fullMatch,
``,
);
}
}
}
return rewrittenContent;
}Components are loaded only when needed:
const MDXContent = lazy(() => import("./MDXContent"));Images are served from GitHub's global CDN with automatic format optimization.
React Router automatically handles code splitting for different routes.
GitHub's CDN provides excellent caching for static content.
We use Fly.io for global edge deployment:
# .github/workflows/fly-deploy.yml
name: Fly Deploy
on:
push:
branches: [main, develop]
paths-ignore: ["content/**"]
jobs:
deploy:
name: 🚀 Deploy
runs-on: ubuntu-latest
needs: [lint, prettier, typecheck]
if: ${{ github.event_name == 'push' }}Sensitive data is managed through environment variables:
const GITHUB_OWNER = process.env.GITHUB_OWNER || "your-username";
const GITHUB_REPO = process.env.GITHUB_REPO || "your-repo";
const GITHUB_BRANCH = process.env.GITHUB_BRANCH || "main";
const GITHUB_TOKEN = process.env.MY_GITHUB_TOKEN;Using GitHub as a CMS proved to be an excellent choice. It provides version control, collaboration features, and global CDN benefits without the complexity of a traditional CMS.
TypeScript caught numerous potential bugs during development and made refactoring much safer.
Fast loading times and smooth interactions are crucial for user engagement. The combination of React Router's optimizations and GitHub's CDN provides excellent performance.
Good tooling, clear error messages, and fast development cycles make the development process enjoyable and productive.
Building this modern web blog has been an exciting journey that combines the best of modern web technologies with practical content management solutions. The result is a fast, maintainable, and scalable platform that provides an excellent experience for both developers and users.
The key to success was choosing the right tools for the job and focusing on user experience from the start. By leveraging GitHub's infrastructure for content management and modern web technologies for the frontend, we've created a blog that's both powerful and simple to maintain.
Whether you're building your own blog or working on a similar project, remember that the best architecture is one that serves your users while making your life as a developer easier. Sometimes the simplest solution—like using GitHub as a CMS—is the most effective.
This blog itself is a testament to the technologies and practices discussed in this post. Every feature mentioned here is actively used in the codebase, and the performance optimizations ensure a smooth reading experience.