evnxt.

Die Suche läuft in deinem Browser, es wird keine Eingabe an uns gesendet.

Dokumentation · Integrationen

Next.js anbinden (App Router)

Beta. Diese Anbindung läuft über den Webhook-Connector. Lies die Seite zuerst — dort stehen Signatur, Ereignisse und Antwortformat. Hier steht nur, wie du sie in Next.js umsetzt.

Next.js hat kein CMS, das wir ansprechen könnten — deine Beiträge liegen da, wo du sie hingelegt hast: als MDX im Dateisystem, in Postgres, in einem Headless-CMS. Deshalb baust du eine Route, die unsere Nachrichten entgegennimmt, und entscheidest darin, wohin der Beitrag geht.

Der Weg ist immer derselbe:

  1. In deiner App einen Route Handler anlegen (unten vollständig).
  2. Ein Geheimnis erzeugen (mindestens 32 zufällige Zeichen) und als EVNXT_SECRET hinterlegen.
  3. Bei uns unter CMS anbinden → Eigene Website (Webhook) die Adresse https://deine-seite.de/api/evnxt und dasselbe Geheimnis eintragen.
  4. Verbindung testen klicken.

Was du wissen musst, bevor du tippst

Der rohe Rumpf zählt. Die Signatur gilt für genau die Bytes, die über die Leitung kommen. Lies den Körper mit await request.text() und parse danachawait request.json() gibt dir ein Objekt, aus dem du die ursprüngliche Zeichenkette nicht mehr zurückbekommst.

Die Route muss dynamisch sein. Ein Route Handler mit POST ist das ohnehin; bei GET sagst du es besser ausdrücklich, sonst backt Next.js dir die Antwort in den Build.

Node-Laufzeit. node:crypto gibt es in der Edge-Laufzeit nicht in dieser Form. Setze export const runtime = 'nodejs'.

Der Route Handler

app/api/evnxt/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import crypto from 'node:crypto';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

const SECRET = process.env.EVNXT_SECRET ?? '';
const BASIS = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://deine-seite.de';
const FENSTER = 300; // fuenf Minuten, wie in webhook-cms.md

type Beitrag = {
  id: number;
  titel: string;
  slug: string;
  teaser: string;
  body_html: string;
  bild_url: string;
  meta_title: string;
  meta_description: string;
  autor: string;
  kategorie: string;
  veroeffentlicht_am: string | null;
};

type Nachricht = {
  ereignis: string;
  zeit: string;
  website: { id: number; name: string };
  remote_id?: string;
  ziel?: string;
  beitrag?: Beitrag;
};

/**
 * Signaturpruefung: sha256= + hex(hmac_sha256(timestamp + "." + rohkoerper)).
 * Zeitkonstant vergleichen — ein == wuerde die Signatur Zeichen fuer Zeichen
 * verraten.
 */
function signaturOk(zeit: string, roh: string, signatur: string): boolean {
  if (!SECRET || !/^\d+$/.test(zeit)) return false;
  if (Math.abs(Date.now() / 1000 - Number(zeit)) > FENSTER) return false;

  const erwartet = 'sha256=' + crypto.createHmac('sha256', SECRET).update(`${zeit}.${roh}`).digest('hex');
  const a = Buffer.from(erwartet, 'utf8');
  const b = Buffer.from(signatur, 'utf8');

  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

export async function POST(request: NextRequest) {
  const roh = await request.text(); // ROH lesen, erst danach parsen
  const zeit = request.headers.get('x-evnxt-timestamp') ?? '';
  const signatur = request.headers.get('x-evnxt-signature') ?? '';

  if (!signaturOk(zeit, roh, signatur)) {
    return new NextResponse(null, { status: 401 });
  }

  let daten: Nachricht;
  try {
    daten = JSON.parse(roh);
  } catch {
    return NextResponse.json({ message: 'Kein gueltiges JSON' }, { status: 422 });
  }

  switch (daten.ereignis) {
    case 'ping':
      // Die Rubriken deiner Seite. Leere Liste ist erlaubt.
      return NextResponse.json({
        ok: true,
        ziele: [
          { id: 'ratgeber', handle: 'ratgeber', title: 'Ratgeber' },
          { id: 'news', handle: 'news', title: 'News' },
        ],
      });

    case 'article.draft':
    case 'article.update': {
      const gespeichert = await speichere(daten, { oeffentlich: false });
      return NextResponse.json({
        remote_id: gespeichert.remote_id,
        remote_version: gespeichert.remote_version,
      });
    }

    case 'article.publish': {
      const gespeichert = await speichere(daten, { oeffentlich: true });
      return NextResponse.json({
        remote_id: gespeichert.remote_id,
        // Erst diese Adresse macht den Beitrag bei uns "oeffentlich".
        remote_url: `${BASIS}/blog/${gespeichert.slug}`,
        remote_version: gespeichert.remote_version,
      });
    }

    case 'article.delete': {
      const id = String(daten.remote_id ?? '');
      if (!id) return NextResponse.json({ message: 'remote_id fehlt' }, { status: 422 });
      await entferne(id);
      return NextResponse.json({ remote_id: id });
    }
  }

  return NextResponse.json({ message: 'Unbekanntes Ereignis' }, { status: 422 });
}

/**
 * Standsabruf. Wird vor jedem Schreiben aufgerufen, damit wir eigene
 * Aenderungen bei dir nicht ueberschreiben. Der Koerper ist leer; signiert
 * wird "<timestamp>." — dieselbe Formel mit leerem zweitem Teil.
 */
export async function GET(request: NextRequest) {
  const zeit = request.headers.get('x-evnxt-timestamp') ?? '';
  const signatur = request.headers.get('x-evnxt-signature') ?? '';

  if (!signaturOk(zeit, '', signatur)) {
    return new NextResponse(null, { status: 401 });
  }

  const id = request.nextUrl.searchParams.get('remote_id') ?? '';
  const eintrag = id ? await lade(id) : null;

  if (!eintrag) {
    // 404 ist ehrlich: Den Beitrag gibt es hier nicht (mehr).
    return NextResponse.json({ message: 'Unbekannt' }, { status: 404 });
  }

  return NextResponse.json({
    remote_id: eintrag.remote_id,
    remote_url: eintrag.oeffentlich ? `${BASIS}/blog/${eintrag.slug}` : null,
    remote_version: eintrag.remote_version,
    entwurf: !eintrag.oeffentlich,
  });
}

Ablegen: MDX im Dateisystem

Die einfache Variante, wenn deine Seite Beiträge aus einem Ordner liest. Achtung: Auf Vercel und ähnlichen Diensten ist das Dateisystem zur Laufzeit nicht beschreibbar — dort brauchst du die Datenbank-Variante weiter unten oder einen Commit ins Repository über die Git-API deines Hosters.

lib/evnxt-store.ts:

import fs from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';

const ORDNER = path.join(process.cwd(), 'content', 'blog');

type Eintrag = {
  remote_id: string;
  slug: string;
  oeffentlich: boolean;
  remote_version: string;
};

export async function speichere(daten: Nachricht, opt: { oeffentlich: boolean }): Promise<Eintrag> {
  const beitrag = daten.beitrag!;
  // Stabile Kennung: bei einem Update schickt uns evnxt die eigene zurueck,
  // beim ersten Anlegen bilden wir sie aus Website und Beitrags-ID.
  const remote_id = daten.remote_id ?? `${daten.website.id}-${beitrag.id}`;

  const front = {
    remote_id,
    title: beitrag.titel,
    description: beitrag.meta_description || beitrag.teaser,
    metaTitle: beitrag.meta_title,
    teaser: beitrag.teaser,
    author: beitrag.autor,
    category: beitrag.kategorie || daten.ziel || '',
    image: await holeBild(beitrag.bild_url, remote_id),
    date: beitrag.veroeffentlicht_am,
    draft: !opt.oeffentlich,
  };

  const inhalt =
    '---\n' +
    Object.entries(front)
      .map(([k, v]) => `${k}: ${JSON.stringify(v ?? '')}`)
      .join('\n') +
    '\n---\n\n' +
    beitrag.body_html +
    '\n';

  await fs.mkdir(ORDNER, { recursive: true });
  await fs.writeFile(path.join(ORDNER, `${beitrag.slug}.mdx`), inhalt, 'utf8');

  return {
    remote_id,
    slug: beitrag.slug,
    oeffentlich: opt.oeffentlich,
    // Der Aenderungsstand darf alles sein, solange er sich bei jeder
    // Aenderung aendert. Ein Hash ueber den Inhalt ist ehrlicher als die
    // Uhrzeit: Er meldet nur echte Aenderungen.
    remote_version: crypto.createHash('sha256').update(inhalt).digest('hex').slice(0, 32),
  };
}

Zu den Bildern: Im body_html und in bild_url stehen Adressen, die auf uns zeigen (/portal-media/…). Lade sie beim Speichern einmal herunter und lege sie unter public/ ab — sonst hängt deine Seite dauerhaft an unserer Erreichbarkeit:

async function holeBild(url: string, remoteId: string): Promise<string> {
  if (!url) return '';
  const res = await fetch(url);
  if (!res.ok) return url; // lieber die Fremdadresse als kein Bild

  const endung = (url.split('.').pop() ?? 'webp').split('?')[0];
  const ziel = `/blog-media/${remoteId}.${endung}`;
  await fs.mkdir(path.join(process.cwd(), 'public', 'blog-media'), { recursive: true });
  await fs.writeFile(path.join(process.cwd(), 'public', ziel), Buffer.from(await res.arrayBuffer()));

  return ziel;
}

Wenn deine Seite statisch gebaut ist, muss nach dem Schreiben noch etwas passieren, damit der Beitrag sichtbar wird — revalidatePath('/blog') bei ISR, sonst ein Deploy-Hook.

Ablegen: Datenbank

Der Weg, der auf jedem Hoster funktioniert. Tabelle (hier Postgres):

create table beitraege (
  remote_id       text primary key,
  slug            text not null unique,
  titel           text not null,
  teaser          text,
  body_html       text,
  meta_title      text,
  meta_description text,
  autor           text,
  kategorie       text,
  bild_url        text,
  oeffentlich     boolean not null default false,
  remote_version  text not null,
  aktualisiert_am timestamptz not null default now()
);
export async function speichere(daten: Nachricht, opt: { oeffentlich: boolean }) {
  const b = daten.beitrag!;
  const remote_id = daten.remote_id ?? `${daten.website.id}-${b.id}`;
  const version = crypto.createHash('sha256')
    .update(JSON.stringify([b.titel, b.teaser, b.body_html, opt.oeffentlich]))
    .digest('hex').slice(0, 32);

  await sql`
    insert into beitraege (remote_id, slug, titel, teaser, body_html, meta_title,
                           meta_description, autor, kategorie, bild_url,
                           oeffentlich, remote_version, aktualisiert_am)
    values (${remote_id}, ${b.slug}, ${b.titel}, ${b.teaser}, ${b.body_html},
            ${b.meta_title}, ${b.meta_description}, ${b.autor},
            ${b.kategorie || daten.ziel || null}, ${b.bild_url},
            ${opt.oeffentlich}, ${version}, now())
    on conflict (remote_id) do update set
      slug = excluded.slug, titel = excluded.titel, teaser = excluded.teaser,
      body_html = excluded.body_html, meta_title = excluded.meta_title,
      meta_description = excluded.meta_description, autor = excluded.autor,
      kategorie = excluded.kategorie, bild_url = excluded.bild_url,
      -- article.update darf einen oeffentlichen Beitrag nicht heimlich
      -- zurueck auf Entwurf setzen.
      oeffentlich = beitraege.oeffentlich or excluded.oeffentlich,
      remote_version = excluded.remote_version, aktualisiert_am = now()
  `;

  return { remote_id, slug: b.slug, oeffentlich: opt.oeffentlich, remote_version: version };
}

entferne(id) löscht die Zeile (und die heruntergeladene Bilddatei), lade(id) liest sie — mehr braucht der Handler nicht.

Testen, ohne auf uns zu warten

Eine signierte Nachricht kannst du dir selbst schicken:

SECRET='dein-geheimnis'
ZEIT=$(date +%s)
BODY='{"ereignis":"ping","zeit":"2026-02-01T10:15:00+01:00","website":{"id":42,"name":"Magazin"}}'
SIG="sha256=$(printf '%s.%s' "$ZEIT" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')"

curl -i https://deine-seite.de/api/evnxt \
  -H "Content-Type: application/json" \
  -H "X-Evnxt-Timestamp: $ZEIT" \
  -H "X-Evnxt-Signature: $SIG" \
  --data "$BODY"

Erwartete Antwort: 200 mit {"ok":true,…}. Kommt 401, stimmt entweder das Geheimnis nicht oder der Rumpf wurde vor der Prüfung umgeformt (siehe oben: request.text(), nicht request.json()).

Häufige Stolpersteine

Symptom Ursache
Immer 401, obwohl das Geheimnis stimmt Der Rumpf wurde neu kodiert. Signiere und prüfe denselben String.
401 nur manchmal Uhr des Servers läuft weg. Das Fenster ist fünf Minuten — prüfe NTP.
Wir melden „Antwort ohne remote_id" Du antwortest 200, aber ohne remote_id. Sie ist Pflicht.
Beitrag bleibt bei uns „nicht öffentlich" Bei article.publish fehlt remote_url. Eine Empfangsbestätigung ist keine Veröffentlichung.
Wir überschreiben deine Änderungen Du meldest keine remote_version. Ohne sie können wir fremde Änderungen nicht erkennen.
403 mit HTML statt JSON Eine Firewall sitzt davor — siehe Cloudflare blockiert die Verbindung.

Weiter

Zur Übersicht Kostenlos starten Etwas fehlt oder stimmt nicht? Schreib an anfrage@wojcik.de.