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:
- In deiner App einen Route Handler anlegen (unten vollständig).
- Ein Geheimnis erzeugen (mindestens 32 zufällige Zeichen) und als
EVNXT_SECREThinterlegen. - Bei uns unter CMS anbinden → Eigene Website (Webhook) die Adresse
https://deine-seite.de/api/evnxtund dasselbe Geheimnis eintragen. - 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 danach — await 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
- Eigene Website anbinden (Webhook) — die Regeln, auf denen diese Seite aufbaut
- Cloudflare blockiert die Verbindung — wenn
403oder503statt deiner Antwort kommt