Dokumentation · Integrationen
Framer anbinden (über einen Vermittlerdienst)
Eingeschränkt. Framer lässt sich nicht direkt anbinden. Es gibt keinen Weg, auf dem unser Server einen Beitrag in dein Framer-CMS schreiben könnte. Was geht, steht auf dieser Seite — und was nicht geht, steht auch drauf. Wenn du eine Anbindung brauchst, die ohne dein Zutun läuft, ist Framer die falsche Wahl; Webflow, Ghost und WordPress können das.
Was Framer heute erlaubt — und was nicht
| Framer | |
|---|---|
| Öffentliche Schreib-API fürs CMS (Server zu Server, mit Token) | nein |
| Plugins, die im geöffneten Framer-Editor ins CMS schreiben (Plugin-API, Managed Collections) | ja |
| CMS-Einträge aus einer eigenen Adresse holen (CMS-Import / „Fetch", CSV oder JSON) | ja, ziehend |
| Unbeaufsichtigter Abgleich, während niemand an Framer sitzt | nein |
Der entscheidende Punkt: Framer holt, wir schicken. Beides trifft sich nur, wenn etwas dazwischen steht, das unsere Nachricht annimmt und sie so ablegt, dass Framer sie holen kann.
Bitte glaube keiner anderen Anleitung, die dir eine Framer-CMS-Adresse mit einem API-Token nennt — die gibt es nicht. Prüfe den aktuellen Stand in der Framer-Entwicklerdokumentation unter framer.com/developers; Framer baut daran, und wenn sich das ändert, ändern wir diese Seite.
Der Aufbau
evnxt ──POST (signiert)──▶ Vermittler ──▶ Speicher (KV / DB)
│
└──GET /feed.json──▶ Framer
(CMS-Import oder dein Plugin)
Der Vermittler ist eine einzige kleine Funktion — eine Cloudflare-Worker-Route, eine Vercel-Function, ein Endpunkt in einer App, die du ohnehin betreibst. Er tut genau zwei Dinge:
- Er nimmt unsere signierten Nachrichten entgegen (das ist der Webhook-Connector, unverändert) und legt die Beiträge in einem Speicher ab.
- Er gibt denselben Bestand als Liste wieder aus, in der Form, die Framer lesen kann.
Bei uns trägst du den Vermittler als Eigene Website (Webhook) ein — für uns ist er die Gegenstelle. Er muss also remote_id zurückmelden, und bei article.publish die spätere öffentliche Adresse deiner Framer-Seite.
Ehrlich zum letzten Schritt: Ob der Beitrag auf deiner Framer-Seite wirklich erscheint, kann der Vermittler nicht wissen — das entscheidet Framer beim nächsten Abgleich. Melde bei article.publish trotzdem die Adresse, unter der der Beitrag stehen wird (slug kennst du ja). Rechne damit, dass zwischen unserer Freigabe und der sichtbaren Seite dein Abgleich liegt.
Teil 1: der Empfänger (echter Code)
Das ist der Teil, den wir garantieren können, denn es ist unsere eigene Signaturregel. Beispiel als Cloudflare Worker mit KV; auf einer Vercel-Function ist es dasselbe mit await request.text() und deiner Datenbank.
// wrangler.toml: [[kv_namespaces]] binding = "BEITRAEGE"
// Geheimnis: wrangler secret put EVNXT_SECRET
const FENSTER = 300; // fuenf Minuten
async function signaturOk(zeit, roh, signatur, secret) {
if (!/^\d+$/.test(zeit)) return false;
if (Math.abs(Date.now() / 1000 - Number(zeit)) > FENSTER) return false;
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const roheSignatur = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${zeit}.${roh}`));
const erwartet = 'sha256=' + [...new Uint8Array(roheSignatur)]
.map((b) => b.toString(16).padStart(2, '0')).join('');
// Zeitkonstant vergleichen: gleiche Laenge, dann Bit fuer Bit.
if (erwartet.length !== signatur.length) return false;
let diff = 0;
for (let i = 0; i < erwartet.length; i++) diff |= erwartet.charCodeAt(i) ^ signatur.charCodeAt(i);
return diff === 0;
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
// --- Der Feed fuer Framer. Oeffentlich lesbar, also: nur veroeffentlichte
// --- Beitraege, keine Entwuerfe.
if (request.method === 'GET' && url.pathname === '/feed.json') {
const liste = await env.BEITRAEGE.list({ prefix: 'beitrag:' });
const alle = await Promise.all(
liste.keys.map((k) => env.BEITRAEGE.get(k.name, 'json'))
);
return Response.json(alle.filter((b) => b && b.oeffentlich));
}
// --- Standsabruf von evnxt (GET mit leerem Koerper)
if (request.method === 'GET' && url.pathname === '/evnxt') {
const zeit = request.headers.get('x-evnxt-timestamp') ?? '';
const sig = request.headers.get('x-evnxt-signature') ?? '';
if (!(await signaturOk(zeit, '', sig, env.EVNXT_SECRET))) return new Response(null, { status: 401 });
const id = url.searchParams.get('remote_id') ?? '';
const eintrag = id ? await env.BEITRAEGE.get(`beitrag:${id}`, 'json') : null;
if (!eintrag) return Response.json({ message: 'Unbekannt' }, { status: 404 });
return Response.json({
remote_id: eintrag.remote_id,
remote_url: eintrag.oeffentlich ? eintrag.remote_url : null,
remote_version: eintrag.remote_version,
entwurf: !eintrag.oeffentlich,
});
}
if (request.method !== 'POST' || url.pathname !== '/evnxt') {
return new Response(null, { status: 404 });
}
const roh = await request.text(); // ROH lesen — die Signatur gilt fuer diese Bytes
const zeit = request.headers.get('x-evnxt-timestamp') ?? '';
const sig = request.headers.get('x-evnxt-signature') ?? '';
if (!(await signaturOk(zeit, roh, sig, env.EVNXT_SECRET))) {
return new Response(null, { status: 401 });
}
const daten = JSON.parse(roh);
if (daten.ereignis === 'ping') {
return Response.json({ ok: true, ziele: [{ id: 'blog', handle: 'blog', title: 'Blog' }] });
}
if (daten.ereignis === 'article.delete') {
const id = String(daten.remote_id ?? '');
if (!id) return Response.json({ message: 'remote_id fehlt' }, { status: 422 });
await env.BEITRAEGE.delete(`beitrag:${id}`);
return Response.json({ remote_id: id });
}
const b = daten.beitrag ?? {};
const oeffentlich = daten.ereignis === 'article.publish';
const remote_id = String(daten.remote_id ?? `${daten.website.id}-${b.id}`);
const alt = await env.BEITRAEGE.get(`beitrag:${remote_id}`, 'json');
if (!['article.draft', 'article.update', 'article.publish'].includes(daten.ereignis)) {
return Response.json({ message: 'Unbekanntes Ereignis' }, { status: 422 });
}
const eintrag = {
remote_id,
slug: b.slug,
titel: b.titel,
teaser: b.teaser,
body_html: b.body_html,
meta_title: b.meta_title,
meta_description: b.meta_description,
autor: b.autor,
kategorie: b.kategorie || daten.ziel || '',
bild_url: b.bild_url,
veroeffentlicht_am: b.veroeffentlicht_am,
// article.update darf einen oeffentlichen Beitrag nicht heimlich
// zurueck auf Entwurf setzen.
oeffentlich: oeffentlich || Boolean(alt?.oeffentlich),
remote_url: `https://deine-seite.framer.website/blog/${b.slug}`,
remote_version: `${Date.now()}`,
};
await env.BEITRAEGE.put(`beitrag:${remote_id}`, JSON.stringify(eintrag));
return Response.json({
remote_id,
remote_url: eintrag.oeffentlich ? eintrag.remote_url : undefined,
remote_version: eintrag.remote_version,
});
},
};
Ob die Signatur stimmt, prüfst du wie überall mit curl — die Anleitung steht unter Next.js anbinden, sie gilt unverändert.
Teil 2: von Framer aus abholen
Ab hier hört unsere Zuständigkeit auf. Beide Wege sind ziehende Wege; welcher zu dir passt, entscheidet dein Framer-Tarif und wie viel du basteln willst. Der maßgebliche Stand steht bei Framer, nicht hier.
Weg A: CMS-Import (kein Code)
Framer kann eine CMS-Collection aus einer Adresse befüllen und später erneut abgleichen. Du richtest die Collection einmal ein, gibst https://vermittler.example/feed.json an und ordnest die Felder zu — titel auf den Titel, body_html auf ein Rich-Text- oder HTML-Feld, slug auf den Slug.
Grenzen, die du kennen solltest, bevor du dich darauf verlässt:
- Der Abgleich ist kein Live-Abgleich. Jemand muss ihn auslösen, oder Framer holt in seinem eigenen Takt.
- HTML in ein Framer-Rich-Text-Feld zu bekommen, ist der wackeligste Teil. Je einfacher dein
body_htmlist (Absätze, Überschriften, Listen, Bilder), desto besser geht es. - Gelöschte Einträge verschwinden nicht zwingend mit. Prüfe, wie dein Framer-Import damit umgeht.
Weg B: ein eigenes Framer-Plugin (Pseudocode)
Ein Plugin läuft im geöffneten Framer-Editor und darf dort ins CMS schreiben. Mit „Managed Collections" gehört die Collection dem Plugin; es kann sie vollständig neu befüllen. Das ist der sauberste Weg — und zugleich der, der nur läuft, während jemand Framer offen hat.
Sinngemäß, nicht zum Kopieren — die gültigen Namen und Signaturen stehen in der Framer-Entwicklerdokumentation (framer.com/developers, Abschnitt Plugins → CMS / Managed Collections):
beim Start des Plugins:
sammlung = die vom Plugin verwaltete Collection holen
bekannt = die zuletzt uebernommene Version je Eintrag aus dem Plugin-Speicher lesen
felder setzen (einmalig, falls noch nicht vorhanden):
titel -> Textfeld
teaser -> Textfeld
body_html -> Rich-Text- oder HTML-Feld
autor, kategorie -> Textfelder
bild -> Bildfeld
slug -> Slug-Feld
feed = HTTP GET https://vermittler.example/feed.json
fuer jeden eintrag in feed:
wenn bekannt[eintrag.remote_id] == eintrag.remote_version:
ueberspringen # unveraendert, nichts zu tun
sonst:
bild = eintrag.bild_url in die Framer-Mediathek hochladen
eintrag in die Collection schreiben (Schluessel: remote_id)
bekannt[eintrag.remote_id] = eintrag.remote_version
fuer jeden eintrag in der Collection, der NICHT mehr im feed steht:
aus der Collection entfernen
bekannt zurueckschreiben
Zwei Dinge, die du dabei nicht vergessen solltest:
- Die Bilder. Unsere Bildadressen zeigen auf uns (
/portal-media/…). Lade sie einmal in die Framer-Mediathek, sonst hängt deine Seite an unserer Erreichbarkeit. remote_versionals Sperre. Nur schreiben, wenn sich der Stand geändert hat — sonst schreibt das Plugin bei jedem Start alles neu und du verlierst Änderungen, die jemand in Framer von Hand gemacht hat.
Womit du rechnen musst
| Fähigkeit | Framer über Vermittler |
|---|---|
| Beitrag ohne dein Zutun veröffentlichen | nein — Framer holt, und zwar nur, wenn jemand oder etwas den Abgleich auslöst |
| Entwürfe, die nicht öffentlich sind | ja — der Vermittler hält sie zurück, der Feed zeigt nur Veröffentlichtes |
| Bilder in deiner Mediathek | ja, aber du lädst sie hoch (Plugin oder Import) |
| Titel und Beschreibung für Suchmaschinen | ja, wenn deine Collection Felder dafür hat |
| Fremde Änderungen erkennen | ja, wenn dein Vermittler remote_version meldet |
| Löschen | abhängig vom Weg — der Import räumt nicht zuverlässig auf, das Plugin schon |
Weiter
- Eigene Website anbinden (Webhook) — Signatur, Ereignisse, Antwortformat
- Next.js anbinden — ein vollständiger Empfänger zum Abschauen
- Cloudflare blockiert die Verbindung — wenn der Vermittler hinter Cloudflare steht