Entwicklung einer modernen Tech-Blogging-Plattform mit Node.js & Next.js

Vorwort – Warum überhaupt noch ein Blog?

Es begann an einem verregneten Dienstagabend. Drei Tabs mit unterschiedlichen Blogsystemen, zwei kalte Espressi, ein Kopf voller Notizen – und das dringende Gefühl, dass keines der vorhandenen Werkzeuge wirklich „meins“ war. Ich wollte keinen sterilen Editor, sondern etwas, das sich wie eine Werkbank anfühlt: ein Tisch, auf dem Text, Code und Ideen durcheinander liegen dürfen, während man an ihnen feilt. Ein Platz, an dem man den Lötkolben neben der Kaffeetasse stehen lassen kann. Viele Tools sind großartig, wenn man linear erzählt. Doch sobald Code ins Spiel kommt, oder mehrere Autor:innen gleichzeitig arbeiten, oder Moderation nötig wird, beginnen selbst die elegantesten Systeme zu knirschen.

Also bauten wir unsere eigene Plattform – mit allen Freuden und Frustrationen, die so ein Projekt mit sich bringt. Dieser Beitrag ist deshalb kein trockenes Feature-Sheet, sondern ein Reisebericht. Er enthält Entscheidungen, Rückschritte, kleine Siege, Anekdoten aus dem Debugging-Alltag und die Freude an jenen Momenten, in denen Software plötzlich „klick“ macht.

Features

  1. ✍️ Beitragserstellung mit Block-Editor (Editor.js)
  2. 🔐 Login mit NextAuth, E-Mail & optional 2FA (TOTP)
  3. 🛠 Admin-Panel mit Benutzerrollen, Moderation & Ticket-System
  4. 💬 Audit-Log für System-Transparenz
  5. 🤖 KI-Unterstützung (OpenAI, Text- und Bild-Generierung)
  6. 📌 Blog-Kategorien, Tags, Slugs & Filter
  7. 🧠 Statusverwaltung: Entwurf, Veröffentlicht, Geplant, Nicht öffentlich
  8. 📊 Statistiken (Besucher, Beiträge, Trends – geplant)
  9. 📬 Newsletter-System
  10. 📨 Benachrichtigungen für Nutzer

Motivation – Das Problem hinter dem Projekt

Technische Texte haben eine Eigenheit: Sie müssen nicht nur verstanden, sondern oft nachvollzogen werden. Code ist nicht Dekoration, er ist Beweis. Er braucht Syntax-Highlighting, klare Typografie, Kopierbarkeit, manchmal sogar Begleittext Zeile für Zeile. Gleichzeitig geschieht Schreiben selten allein. Man entwirft, teilt, holt Feedback und verbessert. Und weil das Netz ein öffentlicher Ort ist, brauchen wir Moderation, Rollen und Nachvollziehbarkeit – ohne dass es nach Behördenflur riecht. Aus dieser Mischung entstand die Idee: Schreiben wie Coden.

Produktvision – Schreiben wie Coden

Im Zentrum steht ein Editor, der sich wie ein Baukasten anfühlt: Text, Überschrift, Code, Zitat, Bild, Diagramm, Hinweis, später beliebige Community-Blöcke. Codeblöcke sind erstklassige Bürger – mit Sprachen, Zeilennummern, Copy-Button, sauberem Rendering auf dem Server und flüssigem Scrollen im Client. Moderationsentscheidungen landen im Audit-Log und sind nachvollziehbar, ohne personenbezogene Daten offenzulegen. Rollen sind fein genug, um Wachstum zu erlauben und grob genug, um noch verstanden zu werden. Sicherheit -> von 2FA bis DSGVO -> ist kein Add-on, sondern Zutatenliste.

Technologieauswahl – Node.js & Next.js als Fundament

Wir wollen Geschwindigkeit im Kopf und im Build. Node.js liefert ein homogenes Ökosystem, in dem sich Backend-APIs, Worker und Tools in TypeScript treffen. Next.js bringt SSR/SSG, ein reifes Routing und gute DX mit. Zusammen ergibt das einen Rahmen, der Experimentierfreude zulässt und sich dennoch in Produktion ruhig verhält.

Eine kleine Chronik der ersten Woche

Am Anfang stand der Editor. Wir bauten ihn dreimal. Erst monolithisch (zu starr) dann zu granular (zu viele Renderzyklen), schließlich blockbasiert, mit einem klaren State-Modell. Das Syntax-Highlighten war zunächst „quick & dirty“ – schön, aber langsam. Nach einer Nacht mit Lighthouse-Messungen wechselten wir auf on-demand geladene Highlighter und SSR-sichere Einbindung. Die erste Integration eines KI-Dienstes erinnerte an das Kaffeeküchen-Telefon: mach mal frei, mach mal besetzt. Also lernten wir, mir Rate-Limits zu leben und Antworten robust zu verarbeiten. Und ganz nebenbei stellten wir fest: Rollen sollte man früh definieren, sonst wachsen sie wie Hecken in Einfahrten.

Architektur im Überblick

Die Anwendung besteht aus drei Schichten:

  • Außen:
    • Next.js – Oberfläche für Lesen, Schreiben, Moderieren
  • Dazwischen:
    • eine API-Schicht, die Eingabe validiert, Rechte prüft, Raten begrenzt und mit der Datenbank spricht
  • Innen:
    • eine rationale Datenbank, die Beiträge, Kommentare, Rollen und Logs verwaltet

Diese Trennung erlaubt uns, die Fassade zu renovieren, ohne statische Wände zu versetzen.

Der Editor – Baustein statt Monolith

Ein Beitrag ist eine Sequenz aus Blöcken. Jeder Block bringt seine eigene Regel mit: Ein Codeblock kennt seine Sprache, ein Bild seinen Alternativtext, ein Hinweis seine Semantik. Wir speichern nicht nur den Text, sondern auch Metadaten, damit spätere Migrationen, Volltextsuche oder das gezielte Rendern (z.B. „zeige mir alle Python-Snippsets“) möglich werden.
Rendering passiert serverseitig, Interaktion clientseitig – beides greift wie ein Zahnrad.

Rollen & Moderation – Verantwortung sichtbar machen

Moderation ist kein Orakel, sondern Handwerk. Wenn ein Kommentar gesperrt wird, entsteht ein Audit-Eintrag: Wer hat gehandelt, welche Ziel, welche Begründung, welcher Kontext? Nicht um zu strafen, sondern um später erklären zu können. Rollen bleiben lesbar: Autor:in, Moderator:in, Admin. Feingranular dort, wo es nötig ist (z.B. „darf veröffentlichen, aber keine Rolle ändern“), grob, wo es besser ist (z.B. „Gast darf vorschlagen“),

Sicherheit & Datenschutz – Schutz als Produktmerkmal

Sicherheit beginnt beim Login und hört bei Logs nicht auf. TOTP-basierte 2FA, Rate-Limits auf API-Routen, Schema-Validation beim Eingeben, sauberes Fehlerhandling. In den Logs vermeiden wir Klartext und speichern Kennungen statt Inhalt. Daten bekommen eine Lebensdauer: Entwürfe, die nicht veröffentlicht wurden, können automatisch bereinigt werden; Anonymisierung ersetzt Löschung, wenn Statistiken weiterleben sollen.

Performance & Skalierung – schnell ist die Voreinstellung

Die Plattform ist auf kurzen Wege gebaut; SSR, Caching am Rand, möglichst wenig Client-JavaScript. Lange Texte werden gestreamt, Bilder vorab skaliert. Die Datenbank erhält Indizes, die aus echten Abfragen entstehen. Und wenn ein Feature zu teuer wird, schreiben wir es um – nicht später, sondern jetzt.

KI-Unterstützung – Helfer, nicht Entscheider

KI ist unser Lektor, nicht unser Chefredakteur. Sie schlägt Tags vor, fasst Absätze zusammen, markiert potenziell problematische Kommentare – und lieg manchmal daneben. Deshalb bleiben alle Entscheidungen reversible, erklärbar und menschlich überprüfbar.

Use-Cases aus der Praxis

1) „Vom Gedanken zur Veröffentlichung“ – ein Team-Beitrag

Eine Autorin skizziert eine Idee im Editor: Einleitung, zwei Codeblöcke, ein Diagramm. Sie setze den Status auf DRAFT und markiert in der Seitenleiste „Review erbeten“. Ein Reviewer kommentiert direkt in Blöcken (kein globales Text-Chaos mehr) und schlägt eine alternative Lösung für ein Snippet vor. Ein Moderator prüft den Ton in den Kommentar und ändert nichts – aber hinterlässt die Spur im Audit-Log. Nach zwei Runden Review wechselt der Status auf READY_FOR_PUBLISH. Der Beitrag bekommt einen Veröffentlichungstermin, ein Social-Preview-Bild und eine kurze Zusammenfassung. Um 09:00 Uhr geht er live, automatisch.

2) „Kommentar-Sturm“ – Moderation in Echtzeit

Ein Beitrag trendet, die Kommentarzahl steigt. Ein Nutzer postet wiederholt aggressive Links. Die Moderation klick nicht wild herum, sondern setzt eine Regel: „Neue Kommentare mit diesem Muster in Warteschlange“. Die Plattform markiert nur auffällige Einträge und informiert die Moderator:innen in einem Slack-Kanal. Später werden die Regeln rückgebaut, die Audit-Einträge bleiben – als Lehrstück für die nächste Welle.

3) „Die Migration“ – vom Alt-Blog ins neue Zuhause

Wir importieren alte Beiträge, die HTML, wie Geröll tragen. Der Importer übersetzt bekannte Muster in Blöcke <pre><code> wird zum Codeblock, <h2> zur Überschrift, Bilder bekommen fehlende Alt-Texte aus Dateinamen (und Warnhinweise, wo das nicht genügt). Der alte Permalink bleibt als Weiterleitung bestehen. Nach dem Import erscheint ein Bericht: fehlende Metadaten, tote Links, zu lange Title. Was sich automatisch heilen lässt, wird geheilt; der Rest bekommt Aufgaben-Tickets.

4) „Der Block aus der Community“ – Erweiterbarkeit in der Praxis

Ein Contributorin baut einen Diagramm-Block. Sie veröffentlicht ihn als Paket, dokumentiert Props und Sicherheitsgrenzen. In unserem System wird der Block mit einer Freigabe versehen: „Neue Blöcke dürfen Text/Code, aber kein Inline-Script“. Nach der Freigabe erscheint er im Editor – rechts neben Bild, Code und Zitat.

Publishing-Workflow – vom ersten Satz bis zum „Jetzt live!“

Veröffentlichen ist mehr als ein Button. Es ist eine Abfolge von Zuständen, die Klarheit schaffen – für Menschen und Maschinen:

  • Entwurf (DRAFT)
    • frei, verspielt, ohne Pflichtfelder
  • Review (IN_REVIEW)
    • Kommentare, Vorschläge, kleine Korrekturen
  • Ready (READY_TO_PUBLISHED)
    • inhaltlich fertig, technisch geprüft
  • Geplant (SCHEDULE)
    • hat Datum/Zeit
  • Veröffentlicht (PUBLISHED)
    • sichtbar, versioniert
  • Zurückgezogen (RETRACTED)
    • öffentlich entfernt, intern nachvollziehbar

Dazu kommen Checks: Hat der Beitrag eine Einleitung? Sind Bilder mit Alt-Text versehen? Sind Codeblöcke einer Sprache zugeordnet? Stimmen Links, stimmen Lizenzen, stimmt der Ton? Manche Prüfungen sind hart (z.B. keine Veröffentlichung ohne Title), andere weich (Warnung, aber nicht blockierend).

Status-Modell in der Datenbank

model Post {
  id         String   @id @default(cuid())
  title      String
  content    Json     // Blockstruktur
  status     PostStatus @default(DRAFT)
  language   String    @default("en")
  publishedAt DateTime?
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt
  authorId   String
}

enum PostStatus {
  DRAFT
  IN_REVIEW
  READY_FOR_PUBLISH
  SCHEDULED
  PUBLISHED
  RETRACTED
}

Übergang mit Guard-Regeln

type Transition = { from: PostStatus; to: PostStatus; guard?: (post: Post) => Promise<boolean> };

const transitions: Transition[] = [
  { from: 'DRAFT', to: 'IN_REVIEW' },
  { from: 'IN_REVIEW', to: 'READY_FOR_PUBLISH', guard: prepublishCheck },
  { from: 'READY_FOR_PUBLISH', to: 'SCHEDULED', guard: hasSchedule },
  { from: 'SCHEDULED', to: 'PUBLISHED' },
  { from: 'PUBLISHED', to: 'RETRACTED' },
];

async function canTransition(post: Post, to: PostStatus) {
  const rule = transitions.find(t => t.from === post.status && t.to === to);
  return rule ? (rule.guard ? rule.guard(post) : true) : false;
}

Pre-Publish-Check: kleine Maschine, große Wirkung

async function prepublishCheck(post: Post) {
  const blocks = post.content as Array<any>;
  const hasIntro = blocks.some(b => b.type === 'paragraph' && b.text.length > 60);
  const imagesNeedAlt = blocks.filter(b => b.type === 'image').some(img => !img.alt || img.alt.length < 3);
  const codeWithoutLang = blocks.filter(b => b.type === 'code').some(c => !c.language);
  if (!post.title || post.title.length < 5) return false;
  if (!hasIntro) return false;
  if (imagesNeedAlt) return false;
  if (codeWithoutLang) return false;
  return true;
}

Geplante Veröffentlichung: pünklich ohne Drama

// Pseudocode eines einfachen Schedulers
import { prisma } from '@/lib/prisma';

export async function runPublishTick(now = new Date()) {
  const due = await prisma.post.findMany({
    where: { status: 'SCHEDULED', publishedAt: { lte: now } },
  });

  for (const post of due) {
    await prisma.$transaction(async (tx) => {
      // Doppelpullover gegen Race Conditions
      const fresh = await tx.post.findUnique({ where: { id: post.id } });
      if (fresh?.status !== 'SCHEDULED') return;
      await tx.post.update({ where: { id: post.id }, data: { status: 'PUBLISHED' } });
      await tx.event.create({ data: { type: 'POST_PUBLISHED', postId: post.id } });
    });
  }
}

Post-Publish – Messen, reden, lernen

Nach der Veröffentlichung sprechen wir mit zwei Systemen: Menschen und Maschinen. Maschinen bekommen Events (z.B. POST_PUBLISHED), die Webhooks auslösen – an Slack, Discord, Suchmaschinen-Ping, Newsletter-Queue. Menschen bekommen verständliche Benachrichtungen: „Dein Beitrag ist live. Hier sind die ersten Reaktionen“

// Minimaler Slack‑Webhook
async function notifySlack(text: string) {
  await fetch(process.env.SLACK_WEBHOOK_URL!, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text }),
  });
}

Beispiele aus dem Maschinenraum – Code plus Erklärung

Einen Blog-Beitrag per API erstellen

Die API ist das Herzstück der Plattform. Sie prüft Methoden, Rolle, Eingabe, drosselt notfalls und schreibt erst dann. Fehlerpfade sind explizit, damit das Frontend sinnvoll reagieren kann.

// pages/api/blog/create.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const BodySchema = z.object({
  title: z.string().min(3).max(200),
  content: z.string().min(1),
  language: z.string().min(2).max(5).default('en'),
});

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '1 m'),
});

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') return res.status(405).json({ error: 'Method Not Allowed' });

  const session = await getServerSession(req, res, authOptions);
  if (!session || !['ADMIN', 'BLOGGER'].includes((session as any).role)) {
    return res.status(403).json({ error: 'Unauthorized' });
  }

  const parsed = BodySchema.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({ error: 'Invalid body', details: parsed.error.flatten() });
  }

  const key = `createPost:${session.user.id}`;
  const { success } = await ratelimit.limit(key);
  if (!success) return res.status(429).json({ error: 'Too Many Requests' });

  try {
    const post = await prisma.blogPost.create({
      data: {
        title: parsed.data.title,
        content: parsed.data.content,
        authorId: session.user.id,
        status: 'DRAFT',
        language: parsed.data.language,
      },
    });
    return res.status(201).json(post);
  } catch (err) {
    return res.status(500).json({ error: 'Failed to create post' });
  }
}

Codeblock mit Syntax-Highlighting – SSR-sicher und mit Copy-Button

'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';
const SyntaxHighlighter = dynamic(() => import('react-syntax-highlighter').then(m => m.Prism), { ssr: false });
import { tomorrow } from 'react-syntax-highlighter/dist/cjs/styles/prism';

export default function CodeBlock({ code, language = 'tsx' }: { code: string; language?: string }) {
  const [copied, setCopied] = useState(false);

  const onCopy = async () => {
    try {
      await navigator.clipboard.writeText(code);
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    } catch {}
  };

  return (
    <figure className="relative">
      <button onClick={onCopy} className="absolute right-2 top-2 rounded px-2 py-1 text-xs bg-gray-200">
        {copied ? 'Kopiert' : 'Copy'}
      </button>
      <SyntaxHighlighter language={language} style={tomorrow} showLineNumbers>
        {code}
      </SyntaxHighlighter>
    </figure>
  );
}

Moderationsentscheidung protokollieren – Audit-Log

await prisma.auditLog.create({
  data: {
    action: 'COMMENT_BLOCKED',
    actorId: moderatorId,
    targetId: commentId,
    details: { reason },
    ip: req.headers['x-forwarded-for'] as string | undefined,
    userAgent: req.headers['user-agent'],
  },
});

2FA mit TOTP – tolerant, aber nicht leichtsinnig

import { authenticator } from 'otplib';

export async function validate2FA(token: string, secret: string, key: string) {
  const isValid = authenticator.verify({ token, secret, window: 1 });
  if (!isValid) return false;
  // throttle(key, 5, '1 m'); // max. fünf Versuche pro Minute (Implementierung projektabhängig)
  return true;
}

Roadmap

🟢 Höchste Priorität

  • Plugin-System für Blockeditor
  • Erweiterte Rollen- und Rechteverwaltung
  • KI-gestütze Moderation

🟠 Mittlere Priorität

  • Live-Collaboration für den Editor
  • Automatische Tag / Kategorie-Vorschläge
  • API für externe Clients
  • CDN-Integration für Medien & Assets

🟡 Niedrige Priorität

  • Voting-System für Feature-Wünsche
  • WebAssembly-Integration
  • Edge-Computing für Moderation


Entdecke mehr von DaonWare

Melde dich für ein Abonnement an, um die neuesten Beiträge per E-Mail zu erhalten.

Kommentar verfassen

Nach oben scrollen