evnxt.

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

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:

  1. Er nimmt unsere signierten Nachrichten entgegen (das ist der Webhook-Connector, unverändert) und legt die Beiträge in einem Speicher ab.
  2. 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_html ist (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_version als 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

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