Zurück zur Übersicht
Tech
Einen modernen Entwickler-Blog mit Remix und MDX bauen
Erfahre Schritt für Schritt, wie du mit Remix, MDX, Vite und TailwindCSS einen performanten Entwickler-Blog aufsetzt, ganz ohne statische Importe.

Wie du mit Remix, MDX und Vite deinen eigenen Blog baust – ganz entspannt!
Du möchtest nicht irgendeinen Blog, du willst Kontrolle, Performance und moderne Tools? In diesem Beitrag zeige ich dir, wie man mit Remix, MDX, Vite und TailwindCSS einen flexiblen Entwickler-Blog aufbauen kann, komplett ohne Headless CMS oder externe Plattformen.
Inhaltsverzeichnis
- So funktioniert das Konzept
- Warum überhaupt Remix, MDX, Vite und TailwindCSS?
- Projekt aufsetzen
- Vite konfigurieren
- Die Blogseiten
So funktioniert das Konzept
Die Idee hinter dem Blog ist einfach, aber clever: Die Blogbeiträge werden als .mdx
Dateien im Projekt gespeichert. MDX ist dabei eine Erweiterung von Markdown, die es erlaubt, JSX-Komponenten direkt im Text zu verwenden. Das heißt: Ich schreibe meine Beiträge ganz normal in Markdown, mit Überschriften, Listen, Codeblöcken etc., und kann gleichzeitig beliebige React-Komponenten wie <Note>
oder <CallToAction>
einbetten.
Beispiel aus einem Beitrag:
# So funktioniert das Konzept
Die Blogbeiträge werden beim Build automatisch erkannt und eingebunden. <-- normaler Text
<CustomComponent title="Hallo" /> <-- JSX-Code
Das Spannende daran ist, was im Hintergrund passiert: MDX-Dateien werden nicht einfach „irgendwie gerendert“, sondern zur Build-Zeit zu echten React-Komponenten kompiliert. Der MDX-Compiler (@mdx-js/rollup
oder @mdx-js/esbuild
) übernimmt dabei die Umwandlung vom MDX-Quelltext in JSX-Code inklusive eingebetteter Komponenten.
So wird aus unserem Beispiel.mdx
:
export default function MDXContent({ components }) {
return (
<>
<h1>So funktioniert das Konzept</h1>
<CustomComponent title="Hallo" />
</>
);
}
So können MDX-Dateien direkt als React-Komponenten verwendet und gerendert werden. Das ist ein entscheidender Unterschied zu klassischen Markdown-Renderern, die HTML ausgeben, aber keine Komponenten unterstützen.
Die .mdx
Dateien können wir über import.meta.glob
einbinden (gleich dazu mehr), was Remix während des Builds alle Dateien bekannt macht. Die eigentliche MDX-Verarbeitung läuft dabei über den Remix-eigenen Vite-Build, das heißt: keine eigene Parsing-Logik, kein externes CMS, keine API-Calls. Einfach nur Markdown + Komponenten.
Das gibt uns maximale Kontrolle, denn:
- Man kann beliebige Komponenten im Beitrag verwenden.
- Man kann z.B für
<CodeSnippet>
eigene Syntaxhervorhebung und Features bauen. - Alles ist zur Build-Zeit verfügbar, keine zusätzliche Client-Logik nötig.
Diese Architektur verbindet die Vorteile von statischen Seiten (schnell, sicher, einfach zu deployen) mit der Flexibilität eines dynamischen Systems, ganz ohne CMS, Admin-Panel oder Server-Overhead. Perfekt für Entwickler, die ihren Content gern selbst in der Hand haben.
Erweitarbares Konzept
Dieses Konzept lässt sich jederzeit erweitern, etwa das die MDX-Inhalte aus einer Datenbank geladen werden. Für kleine Blogs oder den Einstieg ist diese schlanke Lösung aber völlig ausreichend und angenehm wartungsarm.
Warum überhaupt Remix, MDX, Vite und TailwindCSS?
Bevor wir in die Tasten hauen, mal kurz, warum gerade diese Kombi?
- Remix: Modernes React-Framework, das super fix und SEO-freundlich ist. Server-Rendering? Check! Coole Features? Check!
- MDX: Markdown trifft React. Du kannst deine Blogartikel als Markdown schreiben, aber auch React-Komponenten einbauen. Mega flexibel. ✨
- Vite: Der Turbo unter den Build-Tools. Schneller Start, schneller Hot-Reload.
- TailwindCSS: Utility-first CSS, mit dem du blitzschnell schicke Styles zaubern kannst, ohne dich in Stylesheets zu verlieren.
Kurz gesagt: Mit dieser Kombi bist du schnell, flexibel und stylisch unterwegs.
Projekt aufsetzen
Projekt starten mit Remix + Vite
Remix stellt mittlerweile ein Template bereit, das Vite und TailwindCSS bereits vorkonfiguriert, damit ist das Setup kinderleicht:
npx create-remix@latest
Kurz durch den Wizard klicken und voilà, dein Grundgerüst steht.
MDX einbinden: Markdown mit Power
MDX bringt das Beste aus zwei Welten zusammen: Du schreibst deine Blogposts ganz klassisch in Markdown und kannst gleichzeitig React-Komponenten einbauen. So sind deiner Kreativität keine Grenzen gesetzt.
- Installiere das MDX-Rollup Paket für Vite:
npm install -D @mdx-js/rollup remark-frontmatter remark-mdx-frontmatter
- Durch das Rollup-Plugin werden die MDX Dateien zur Build-Zeit zu React-Komponenten kompiliert. Das heißt: Eine
.mdx
Datei bildet einen Blogbeitrag in deinem Projekt und kann direkt importiert und gerendert werden. - Du kannst sogar jede React Komponente wie Alert-Boxen, Bilder-Galerien oder Videos in deinen Artikeln nutzen. Die React-Komponenten kannst du einfach am Anfang der
.mdx
Datei nach dem frontmatter importieren, z. B.:
import { Alert } from '~/components/Alert';
import { Galery } from '~/components/Galery';
Und dann direkt im Text verwenden:
<Alert type="info">
Diese Box ist eine React-Komponente – direkt im Markdown!
</Alert>
Damit hast du volle Kontrolle und Flexibilität beim Schreiben deiner Beiträge, ohne auf die Vorteile eines statischen Setups zu verzichten.
Vite konfigurieren
Jetzt binden wir das @mdx-js/rollup
Plugin in unsere vite.config.ts
ein. Weitere Plugins, etwa für Syntax-Highlighting, lassen sich genauso leicht ergänzen:
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import mdx from '@mdx-js/rollup';
import remarkFrontmatter from 'remark-frontmatter';
import remarkMdxFrontmatter from 'remark-mdx-frontmatter';
export default defineConfig({
plugins: [
mdx({
remarkPlugins: [
remarkFrontmatter,
[remarkMdxFrontmatter, { name: 'frontmatter' }],
//Weitere Markdownerweiterungen bei Bedarf hinzufügen
//z.B. remark-toc für automatisch erstelltes Inhaltsverzeichnis
],
rehypePlugins: [],
}),
//.. remix und andere plugins
],
});
Die Blogseiten
Wir werden jetzt in den /routes
Ordner drei neue Seiten anlegen, damit der Blog unter /blog
aufrufbar sein wird.
Ich werde mich vor allem auf den relevanten funktionalen Code beschränken. Das Styling überlasse ich dir 😄.
Wenn du möchtest, dass dein Blog direkt unter /
, also auf deiner Startseite aufrufbar ist, so musst du die Dateien direkt index.tsx nennen usw.
blog.tsx
für das Layoutblog._index.tsx
für die Blogübersichtblog.$slug._index.tsx
für die eigentliche Blogbeitragsseite
Blog-Layout
Das Blog-Layout hat die Aufgabe ein einheitliches Layout für all unsere Blogrouten zu schaffen, damit wir Navbar und Footer nicht auf jeder Seite manuell hinzufügen müssen.
import BlogNavbar from "~/components/blog/BlogNavbar"; //Oft bietet es sich an, eine eigene Navbar für seinen Blog zu erstellen, es kommt aber ganz auf deine Ziele an
import {Outlet} from "@remix-run/react";
import BlogFooter from "~/components/blog/BlogFooter"; //Selbe Spiel wie bei der Navbar
import Container from "~/components/Container";
export default function BlogLayout() {
return (
<>
<BlogNavbar />
<Container className={`flex-1 w-full`}>
<Outlet /> {/* Hier wird der Seiteninhalt gerendert */}
</Container>
<BlogFooter />
</>
);
}
Blogübersicht
Am Anfang habe ich erwähnt, dass die .mdx
Dateien zu React Komponenten kompiliert werden, daher können wir sie auch einfach direkt in unseren Seiten importieren.
import * as my-post from "~/routes/posts/my-post.mdx";
Das wäre zwar möglich, aber bei einer größeren Anzahl an Posts ziemlich unpraktisch – deshalb bauen wir uns eine dynamische Lösung.
Die Blogübersicht soll folgendes können:
- Artikel dynamisch importieren
- Anhand des Artikeldatums sortieren können
- Auflistung der Artikel inkl. Weiterleitung
import {Link, useLoaderData} from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/node";
import {Frontmatter} from "~/common/blogUtils";
const modules = import.meta.glob("./your-path/*.mdx", { eager: true });
export async function loader({}: LoaderFunctionArgs) {
const posts = Object.entries(modules)
.map(([filepath, mod]) => {
const slug = filepath.split("/").pop()?.replace(".mdx", "") ?? "unknown";
const { frontmatter } = mod as { frontmatter: Frontmatter };
return {
slug,
frontmatter,
date: frontmatter.date ? new Date(frontmatter.date) : new Date(0),
};
})
.sort((a, b) => b.date.getTime() - a.date.getTime());
const latest = posts.slice(0, 3);
const categories = Array.from(
new Set(posts.map((post) => post.frontmatter.category).filter(Boolean))
) as string[];
return Response.json({ latest, categories });
}
export default function BlogOverview() {
const { latest, categories } = useLoaderData<typeof loader>();
return (
<div className="">
<h1 className="text-3xl font-bold mb-6">Neueste Beiträge</h1>
<div className="grid md:grid-cols-3 gap-6 mb-10">
{latest.map((post: any) => (
<Link
key={post.slug}
to={`/blog/${post.slug}`}
className="block p-4 border rounded-xl shadow-sm hover:shadow-md transition"
>
<h2 className="text-xl font-semibold">{post.frontmatter.title}</h2>
<p className="text-sm text-gray-500">{post.frontmatter.date}</p>
<p className="mt-2 text-gray-700 text-sm">
{post.frontmatter.description}
</p>
</Link>
))}
</div>
<h2 className="text-2xl font-bold mb-4">Kategorien</h2>
<div className="flex flex-wrap gap-2">
{categories.map((cat: any) => (
<span
key={cat}
className="bg-gray-200 text-gray-700 px-3 py-1 rounded-full text-sm"
>
{cat}
</span>
))}
</div>
</div>
);
}
Und hier noch die Utils:
export type Frontmatter = {
title: string;
date?: string;
description?: string;
category?: string;
tags: string[];
};
Beitragsseite
Kommen wir zu dem Herzstück des Blogs: die Beitragsseite
Hier können wir nun den Inhalt des Artikels anzeigen:
import { useLoaderData } from "@remix-run/react";
import {Frontmatter} from "~/common/blogUtils";
// Dynamisch alle .mdx-Dateien importieren (zur Build-Zeit)
const modules = import.meta.glob("./your-path/*.mdx", { eager: true });
export async function loader({ params }: { params: { slug: string } }) {
const slug = params.slug;
if (!slug) throw new Response("Not Found", { status: 404 });
const matchedEntry = Object.entries(modules).find(([file]) =>
file.endsWith(`/${slug}.mdx`)
);
if (!matchedEntry) throw new Response("Not Found", { status: 404 });
const [filePath, mod] = matchedEntry;
const { frontmatter } = mod as {
default: any;
frontmatter: Frontmatter;
};
return { frontmatter, slug };
}
export default function BlogPost() {
const { frontmatter, slug } = useLoaderData<typeof loader>();
const matchedEntry = Object.entries(modules).find(([file]) =>
file.endsWith(`/${slug}.mdx`)
);
if (!matchedEntry) {
return <p>Beitrag nicht gefunden</p>;
}
const mod = matchedEntry[1] as {
default: React.ComponentType<any>;
};
const MDXContent = mod.default;
return (
<article className="mx-auto px-4 py-12">
{/* Header */}
<header className="mb-10">
<p className="text-sm text-blue-600 uppercase font-semibold tracking-wide">
{frontmatter.category}
</p>
<h1 className="text-4xl font-extrabold text-gray-900 dark:text-white mt-2">
{frontmatter.title}
</h1>
{frontmatter.description && (
<p className="mt-3 text-lg text-gray-600 dark:text-gray-300">
{frontmatter.description}
</p>
)}
<div className="mt-4 text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-3">
<time>
{new Date(frontmatter.date ?? new Date()).toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
{frontmatter.tags?.length > 0 && (
<>
<span>·</span>
<span>
{frontmatter.tags.map((tag, idx) => (
<span key={tag}>
#{tag}
{idx < frontmatter.tags.length - 1 ? ", " : ""}
</span>
))}
</span>
</>
)}
</div>
</header>
{/* Content */}
<section className="prose prose-lg dark:prose-invert max-w-none [&_a]:no-underline">
<MDXContent components={{}}/>
</section>
</article>
);
}
Achtung bei unvertrauenswürdigen Autoren
Der Einsatz von Markdown- bzw. MDX-Dateien als Content-Quelle empfiehlt sich nur, wenn die Autoren vertrauenswürdig sind. Da MDX auch JavaScript-Code enthalten kann, besteht die Gefahr, dass unsichere oder schädliche Inhalte im Blog ausgeführt werden. Für den eigenen Blog aber absolut unbedenklich.
🔒 Tipp: Wenn du Beiträge von Dritten zulässt, solltest du überlegen, MDX vor der Kompilierung zu validieren oder ein statisches Prüfskript zu schreiben, das z. B. import, eval oder bestimmte Komponenten blockiert.
Unser erster Artikel
Das war es auch schon! Jetzt können wir unseren ersten Artikel verfassen. Der Artikel besteht aus zwei Bereichen, dem Frontmatter, ein bisschen so wie der <head>
Bereich in HTML und dem eigentlichen Content.
Der Frontmatter Abschnitt wird durch drei -
Bindestriche vom Content getrennt.
---
title: "Mein erster Blogbeitrag"
description: "Venisly ist einfach großartig"
date: "2025-07-04"
category: "Tech"
tags: ["Remix", "MDX", "Blog"]
---
Hallo Welt! Ich bin ein Blogartikel, der viel zu sagen hat:
- Jede menge Wörter
- Selbstironische Auflistung
- Zu guter Letz, das Ende 😄
## Das ist übrigens mein erster Artikel
Selbstverständlich haben wir damit noch keinen produktionsreifen Blog, aber bereits ein solides Fundament! 🚀 Ich hoffe du fandest diesen Beitrag hilfreich und schicke mir gerne einen Link von deinem Blog, wenn er live geht.
Danke fürs Lesen!