← Zurueck zum Blog
blogmdxsupabaseadminnext-jsrlseditor

Ein Blogsystem von Grund auf — MDX, Admin-Editor und Self-Hosted Supabase

Marco Carstensen·8. März 2026·8 Min. Lesezeit

Warum ein eigenes Blogsystem?

Es gibt Dutzende Blog-Loesungen: WordPress, Ghost, Hashnode, MDX-Dateien im Repo. Jede hat Vorteile. Aber fuer macip.de passte keine perfekt:

  • WordPress/Ghost: Externer Service, separates Hosting, DSGVO-Aufwand
  • Hashnode/Medium: Content auf fremder Plattform, kein Self-Hosting
  • MDX im Repo: Kein Editor, jeder Beitrag braucht ein Git-Commit und Redeploy
  • Headless CMS (Strapi, Sanity): Zusaetzliche Infrastruktur, oft Overkill fuer einen Solo-Blog

Die Loesung: Ein eigenes Blogsystem, das die bereits vorhandene Supabase-Instanz nutzt. Content in der Datenbank, MDX-Rendering auf dem Server, Admin-Editor im bestehenden Dashboard.


Die Architektur

Das System hat zwei getrennte Zugangswege:

  • Oeffentliche Blog-Seiten (/blog) — Server Components, die veroeffentlichte Posts aus der Datenbank laden und MDX serverseitig rendern
  • Admin-Bereich (/dashboard/blog) — Authentifizierte API-Routes fuer CRUD-Operationen, Editor mit Live-Preview

Die oeffentlichen Seiten nutzen einen privilegierten Datenbank-Client, der nur veroeffentlichte Posts liest. Der Admin-Bereich prueft auf jeder Route die Berechtigung und nutzt geschuetzte API-Endpoints.


Die Datenbank: blog_posts

Die Tabelle ist bewusst einfach gehalten:

CREATE TABLE blog_posts (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  slug         TEXT UNIQUE NOT NULL,
  title        TEXT NOT NULL,
  excerpt      TEXT NOT NULL DEFAULT '',
  content      TEXT NOT NULL DEFAULT '',
  tags         TEXT[] NOT NULL DEFAULT '{}',
  status       TEXT NOT NULL DEFAULT 'draft'
               CHECK (status IN ('draft', 'published')),
  published_at TIMESTAMPTZ,
  created_at   TIMESTAMPTZ DEFAULT now(),
  updated_at   TIMESTAMPTZ DEFAULT now(),
  author_email TEXT
);

Der Content wird als roher MDX-String gespeichert — kein JSON, kein AST, kein proprietaeres Format. Das macht den Content portabel: Man koennte ihn jederzeit als .mdx-Dateien exportieren.

Tags sind ein PostgreSQL-Array (TEXT[]). Kein Join-Table, keine Tag-Entitaet — fuer einen Solo-Blog ist ein Array die pragmatischste Loesung. PostgreSQL kann Arrays nativ filtern, indizieren und durchsuchen.

Automatisches updated_at

Ein Trigger aktualisiert updated_at bei jedem UPDATE automatisch. Das SET search_path in der Trigger-Funktion ist eine Best-Practice, die verhindert, dass die Funktion ueber manipulierte Search-Paths beeinflusst werden kann.

Performance-Indexes

CREATE INDEX idx_blog_posts_status ON blog_posts (status);
CREATE INDEX idx_blog_posts_published_at ON blog_posts (published_at DESC)
  WHERE status = 'published';

Der Partial Index auf published_at ist der Schluessel: Er indiziert nur veroeffentlichte Posts und deckt damit genau die Queries der oeffentlichen Blog-Seite ab.


Datenbankebene-Sicherheit

Row Level Security (RLS) schuetzt die Daten direkt auf Datenbankebene. Selbst wenn jemand die API-URL kennt, kann er nur das sehen, was die Policies erlauben.

Die Policies sind klar getrennt:

  1. Anonyme User sehen nur veroeffentlichte Posts (SELECT)
  2. Authentifizierte Admins haben vollen CRUD-Zugriff (ALL)

Die Admin-Pruefung erfolgt ueber eine zentrale Datenbankfunktion, die die E-Mail-Adresse aus dem JWT gegen eine Berechtigungstabelle prueft. Die Funktion wird als STABLE markiert, sodass PostgreSQL sie pro Transaktion nur einmal auswertet.

Ein wichtiger Performance-Trick: Funktionsaufrufe in RLS-Policies sollten immer in einen (SELECT ...) Wrapper gepackt werden. Ohne den Wrapper evaluiert PostgreSQL den Ausdruck fuer jede Zeile — mit dem Wrapper wird er als InitPlan einmalig ausgewertet.


MDX-Rendering

Das MDX-System basiert auf next-mdx-remote (v6) mit rehype-pretty-code fuer Syntax-Highlighting.

Die Render-Pipeline

import { compileMDX } from "next-mdx-remote/rsc";
import remarkGfm from "remark-gfm";
import rehypePrettyCode from "rehype-pretty-code";
 
export async function renderMDX(source: string) {
  const { content } = await compileMDX({
    source,
    components: mdxComponents,
    options: {
      mdxOptions: {
        remarkPlugins: [remarkGfm],
        rehypePlugins: [
          [rehypePrettyCode, {
            theme: "github-dark-default",
            keepBackground: false,
          }],
        ],
      },
    },
  });
  return content;
}

compileMDX aus next-mdx-remote/rsc ist entscheidend: Das /rsc bedeutet, dass die Kompilierung in einem React Server Component stattfindet. Der MDX-String wird auf dem Server in React-Elemente umgewandelt — der Browser bekommt fertiges HTML, kein JavaScript-Bundle fuer den MDX-Parser.

remark-gfm erweitert den Standard-Markdown-Parser um GitHub Flavored Markdown — damit funktionieren Tabellen, Strikethrough, Autolinks und Task-Listen.

Shiki fuer Syntax-Highlighting

rehype-pretty-code nutzt Shiki als Highlighter. Shiki unterscheidet sich von Prism/Highlight.js: Es verwendet die gleichen TextMate-Grammatiken wie VS Code und erzeugt Inline-Styles statt CSS-Klassen.

Das Theme github-dark-default passt perfekt zum Dark-Design der Seite. Highlighted Lines bekommen einen blauen Left-Border und einen dezenten blauen Hintergrund — konsistent mit der Akzent-Farbe des Blog-Bereichs.

Custom MDX-Components

Jedes HTML-Element, das MDX erzeugt, wird durch eine eigene React-Komponente ersetzt. Das stellt sicher, dass der gerenderte Content exakt dem Design-System entspricht:

  • Ueberschriften nutzen font-sans (Inter) mit verschiedenen Gewichten
  • Body-Text nutzt font-mono (JetBrains Mono) mit grosszuegigem Line-Height
  • Links erkennen automatisch externe URLs und setzen target="_blank"
  • Inline-Code bekommt die blaue Akzent-Farbe
  • Blockquotes haben einen blauen Left-Border und den Card-Hintergrund
  • Tabellen bekommen das Design-System-Styling mit border-subtle

Lesezeit-Schaetzung

export function estimateReadingTime(content: string): number {
  const words = content.trim().split(/\s+/).length;
  return Math.max(1, Math.round(words / 200));
}

200 Woerter pro Minute ist der Standard fuer technische Texte. Minimum ist 1 Minute — auch fuer sehr kurze Posts.


Die oeffentliche Blog-Seite

Blog-Uebersicht (/blog)

Die Blog-Uebersicht ist ein Server Component. Sie laedt alle veroeffentlichten Posts, berechnet die Lesezeit und uebergibt alles an einen Client-seitigen Tag-Filter.

Tags werden aus allen Posts extrahiert, dedupliziert und als filterbare Buttons in der Blog-Akzentfarbe (Blau) dargestellt. Klickt man einen Tag, werden nur Posts mit diesem Tag angezeigt. Nochmal klicken entfernt den Filter.

Die BlogCard-Komponente

BlogCards folgen dem gleichen Pattern wie ProjectCards — mit angepasster Semantik:

+------------------------------+
|  ==========  (Gradient)      |
|                              |
|  next-js  supabase           |
|  Wie macip.de entstand       |
|  Von der ersten Zeile Code   |
|  bis zum produktiven...      |
|                              |
|  8. Maerz 2026 - 12 Min.     |
|                  Lesen ->    |
+------------------------------+

Statt "Ansehen" (Projekte) steht hier "Lesen", und die Meta-Zeile zeigt Datum + Lesezeit

Detail-Seite (/blog/[slug])

Der Content wird serverseitig gerendert. renderMDX() kompiliert den MDX-String zu React-Elementen, rehype-pretty-code fuegt das Syntax-Highlighting hinzu. Der Browser bekommt fertiges, gestyltes HTML.

Jede Detail-Seite liefert JSON-LD (BlogPosting) fuer Google und zeigt Meta-Informationen: Autor, Datum (deutsch formatiert), Lesezeit.


Der Admin-Editor

Post-Verwaltung (/dashboard/blog)

Die Admin-Uebersicht zeigt alle Posts — Entwuerfe und veroeffentlichte. Filterbar nach Status ueber Tabs: Alle, Veroeffentlicht, Entwuerfe.

Der MDX-Editor

Das Herzstueck: Ein Split-View-Editor mit MDX-Textarea und Live-Preview.

+-----------------+-----------------+
|  Editor         |  Preview        |
|                 |                 |
|  ## Titel       |  Titel          |
|                 |  ---------      |
|  Ein Absatz     |  Ein Absatz     |
|  mit **fett**   |  mit fett       |
|                 |                 |
|  ```ts          |  +------------+ |
|  const x = 1;   |  | const x= 1 | |
|  ```            |  +------------+ |
+-----------------+-----------------+

Links schreibt man Markdown, rechts sieht man sofort das gerenderte Ergebnis. Der Editor hat folgende Felder:

  • Titel — wird zum Auto-Slug
  • Slug — editierbar, Auto-generiert aus dem Titel
  • Excerpt — Kurzbeschreibung fuer die Karte
  • Tags — Komma-getrennte Eingabe mit Live-Preview als Chips
  • Content — MDX-Textarea mit MdxToolbar

Die MdxToolbar

Eine Toolbar ueber dem Textarea mit 8 Aktionen: Bold, Italic, Code, H2, H3, Link, Liste und Codeblock.

Jeder Button fuegt die entsprechende Markdown-Syntax an der Cursor-Position ein. Wenn Text selektiert ist, wird er umschlossen. requestAnimationFrame stellt sicher, dass die Cursor-Position erst nach dem React-Re-Render aktualisiert wird.

Client-Side Preview

Die Live-Preview nutzt eine Regex-basierte Markdown-zu-HTML-Konvertierung. Warum Regex statt echtem MDX-Rendering? Weil die Server-seitige compileMDX-Funktion in Client Components nicht verfuegbar ist — Turbopack blockiert den Import von react-dom/server.

Die Regex-Preview ist kein perfektes MDX-Rendering, aber sie gibt beim Schreiben eine ausreichende Vorstellung des Ergebnisses. Das finale Rendering passiert serverseitig auf der oeffentlichen Seite.


Die API-Routes

Erstellen (POST)

Die API generiert automatisch einen URL-freundlichen Slug aus dem Titel (mit deutscher Umlaut-Konvertierung) und prueft auf Einzigartigkeit. published_at wird nur gesetzt, wenn der Post direkt als "published" erstellt wird — das Datum ist der Zeitpunkt der ersten Veroeffentlichung, nicht der Erstellung.

Aktualisieren (PATCH)

Ein Entwurf, der spaeter veroeffentlicht wird, bekommt das published_at-Datum zum Zeitpunkt der Veroeffentlichung. Wird der Post danach nochmal bearbeitet, bleibt das Original-Datum erhalten.

Loeschen (DELETE)

Einfache Auth-Pruefung + Delete. Keine Soft-Deletes, kein Papierkorb — fuer einen Solo-Blog unnoetige Komplexitaet.


Dashboard-Integration

Das Blog-Modul ist nahtlos in das bestehende Dashboard integriert. Die Uebersichtsseite zeigt Blog-Statistiken (Gesamtzahl, Entwuerfe) neben den Kontaktanfragen — alle Queries laufen parallel mit Promise.all(). Quick-Links fuehren direkt zum Blog-Manager und zum "Neuer Beitrag"-Editor.


Was ich gelernt habe

  1. MDX im Server Component ist maechtig: Kein Client-Bundle fuer den Parser, Syntax-Highlighting auf dem Server, Custom Components fuer konsistentes Design.

  2. Row Level Security ist nicht optional: Jede Tabelle braucht Policies. Eine zentrale Helper-Funktion macht die Policies lesbar und wartbar.

  3. Regex-Preview ist gut genug: Man braucht keine perfekte MDX-Preview im Editor. Eine Regex-basierte Annaeherung reicht zum Schreiben — das finale Rendering passiert auf dem Server.

  4. remark-gfm nicht vergessen: Ohne dieses Plugin funktionieren Markdown-Tabellen nicht. Es erweitert den Standard-Parser um GitHub Flavored Markdown.

  5. Performance in Policies beachten: Ein (SELECT function()) Wrapper macht den Unterschied zwischen einer Auswertung pro Transaktion und einer pro Zeile.


Fazit

Das Blogsystem von macip.de zeigt, dass man fuer einen technischen Blog kein externes CMS braucht. Mit Next.js Server Components, MDX und einer bestehenden Supabase-Instanz laesst sich ein vollstaendiges System bauen:

  • MDX in der Datenbank — portabel, editierbar, kein File-System noetig
  • Server-Side Rendering — Syntax-Highlighting ohne Client-JS
  • Admin-Editor — Split-View mit Live-Preview, direkt im Dashboard
  • Datenbankebene-Sicherheit — Row Level Security, nicht nur API-Ebene
  • Self-Hosted — Volle Kontrolle, keine Abhaengigkeiten

Der gesamte Blog — von der Datenbank ueber die API bis zum Editor — laeuft auf dem gleichen Proxmox-Cluster wie der Rest der Seite. Keine externen Services, keine monatlichen Kosten, volle Kontrolle.

Ein Blogsystem von Grund auf — MDX, Admin-Editor und Self-Hosted Supabase – macip.de