← Zurueck zum Blog
BlueskyAT ProtocolNext.jsTypeScriptAPI

Bluesky-Integration: Blog-Artikel automatisch auf Bluesky posten

Marco Carstensen·8. März 2026·4 Min. Lesezeit

Nach der X.com-Integration via Playwright war klar: Ein zweiter Social-Media-Kanal muss her. Die Wahl fiel auf Bluesky — ein dezentrales soziales Netzwerk, das auf dem offenen AT Protocol basiert.

Der entscheidende Vorteil gegenueber X.com: Bluesky bietet eine offizielle, gut dokumentierte API. Kein Playwright, keine Browser-Automatisierung, keine fragilen Selektoren. Ein simpler HTTP-Call genuegt.

Architektur im Vergleich

Bei X.com sieht der Posting-Flow so aus:

  1. Teaser generieren (Ollama auf dem Mac)
  2. In die Warteschlange einreihen
  3. PM2-Worker pollt alle 60 Sekunden
  4. Playwright startet headless Chromium
  5. Navigiert zu X.com, findet Textfeld, tippt Text, klickt Button
  6. Wartet auf API-Response im Netzwerk-Tab
  7. Extrahiert Post-ID aus der Response

Bei Bluesky:

  1. Teaser ist bereits generiert (gleicher Text wie fuer X)
  2. API-Call an bsky.social mit App Password
  3. Fertig.

Kein Worker, kein Browser, keine Session-Verwaltung. Der gesamte Bluesky-Client ist unter 60 Zeilen Code.

Technische Umsetzung

AT Protocol SDK

Bluesky nutzt das AT Protocol (Authenticated Transfer Protocol). Das offizielle npm-Paket @atproto/api bietet einen vollstaendigen Client:

npm install @atproto/api

Authentifizierung via App Password

Statt OAuth oder API-Keys nutzt Bluesky App Passwords — spezielle Passwoerter, die in den Bluesky-Einstellungen erstellt werden. Sie haben eingeschraenkte Rechte (koennen z.B. das Konto-Passwort nicht aendern) und sind jederzeit widerrufbar.

import { BskyAgent } from "@atproto/api";
 
const agent = new BskyAgent({ service: "https://bsky.social" });
await agent.login({
  identifier: "mein-handle.bsky.social",
  password: process.env.BLUESKY_APP_PASSWORD,
});

Rich Text und Facets

Ein Bluesky-Post ist nicht einfach nur Text. Links, Mentions und Hashtags werden als Facets kodiert — Annotationen mit Byte-Offsets, die angeben, welcher Textbereich ein Link ist.

Das SDK nimmt einem die Arbeit ab:

import { RichText } from "@atproto/api";
 
const richText = new RichText({
  text: "Mein Teaser-Text\n\nhttps://www.macip.de/blog/..."
});
await richText.detectFacets(agent);
 
await agent.post({
  text: richText.text,
  facets: richText.facets,
});

detectFacets() erkennt automatisch URLs im Text und erstellt die passenden Facet-Objekte mit korrekten Byte-Offsets. Das ist wichtig, weil Bluesky UTF-8-Byte-Offsets verwendet, nicht Character-Indizes — bei Umlauten oder Emojis wuerde man sonst falsche Positionen berechnen.

Der Bluesky-Client

Der gesamte Client in src/lib/bluesky/client.ts:

export async function publishToBluesky(
  text: string,
  url: string
): Promise<{ uri: string; postUrl: string }> {
  const agent = new BskyAgent({ service: "https://bsky.social" });
  await agent.login({
    identifier: BLUESKY_IDENTIFIER,
    password: BLUESKY_APP_PASSWORD,
  });
 
  const fullText = `${text}\n\n${url}`;
  const richText = new RichText({ text: fullText });
  await richText.detectFacets(agent);
 
  const response = await agent.post({
    text: richText.text,
    facets: richText.facets,
    createdAt: new Date().toISOString(),
  });
 
  const rkey = response.uri.split("/").pop();
  const postUrl = `https://bsky.app/profile/${BLUESKY_IDENTIFIER}/post/${rkey}`;
 
  return { uri: response.uri, postUrl };
}

Der Text wird aus dem bereits generierten X-Teaser (x_main_tweet) uebernommen — mit maximal 280 Zeichen passt er problemlos in Bluesky's 300-Zeichen-Limit. Die Blog-URL wird mit Bluesky-spezifischen UTM-Parametern angehaengt.

Integration in den Blog-Editor

Im Dashboard erscheint unter dem X.com-Bereich ein neuer Bluesky-Bereich:

  • Vorschau des Posts (gleicher Teaser-Text wie fuer X)
  • "Auf Bluesky posten"-Button
  • Status-Badge: Veroeffentlicht (gruen) oder Fehlgeschlagen (rot)
  • Link zum Post nach erfolgreicher Veroeffentlichung

Der Button ist deaktiviert, wenn noch kein Teaser generiert wurde oder der Post bereits auf Bluesky veroeffentlicht ist.

API-Route

Die Route POST /api/blog/[id]/publish-bsky folgt dem gleichen Muster wie die X.com-Routen:

  1. Admin-Authentifizierung pruefen
  2. Post laden und validieren (veroeffentlicht, Teaser vorhanden)
  3. Pruefen ob bereits auf Bluesky gepostet
  4. publishToBluesky() aufrufen
  5. Ergebnis in der Datenbank speichern
  6. Logging in x_publish_logs

Bei Erfolg werden bsky_post_uri, bsky_post_url und bsky_shared_at gespeichert. Bei Fehlern wird bsky_publish_status auf failed gesetzt und die Fehlermeldung in bsky_publish_error protokolliert.

Warum kein Worker?

Bei X.com ist ein PM2-Worker noetig, weil:

  • Playwright braucht eine persistente Browser-Session
  • X.com hat aggressive Rate-Limits und Bot-Erkennung
  • Browser-Automatisierung ist fehleranfaellig und langsam
  • Ein fehlgeschlagener Post soll automatisch erneut versucht werden

Bei Bluesky entfallen all diese Gruende:

  • Die API ist zuverlaessig und schnell (unter 2 Sekunden)
  • App Passwords sind fuer maschinellen Zugriff gedacht
  • Bei einem Fehler kann der User einfach erneut klicken
  • Kein Session-Management noetig

Datenbank-Erweiterung

Fuenf neue Spalten auf der blog_posts-Tabelle:

SpalteTypBeschreibung
bsky_post_uritextAT-URI des Posts
bsky_post_urltextWeb-URL zum Post
bsky_publish_statustextnull / published / failed
bsky_publish_errortextFehlermeldung
bsky_shared_attimestamptzZeitpunkt der Veroeffentlichung

Fazit

Die Bluesky-Integration zeigt den Unterschied zwischen einer API-first-Plattform und einer, die APIs aktiv einschraenkt. Waehrend die X.com-Integration ueber 500 Zeilen Playwright-Code, einen PM2-Worker und aufwaendige Session-Verwaltung benoetigt, kommt Bluesky mit unter 60 Zeilen aus.

Der gesamte Stack:

  • @atproto/api — Offizielles SDK
  • App Password — Einfache, sichere Authentifizierung
  • RichText + Facets — Automatische Link-Erkennung
  • Synchroner API-Call — Kein Worker noetig

Das Ergebnis: Ein Klick im Dashboard, und der Blog-Artikel ist auf Bluesky.


Quellcode: Die Implementierung ist Teil des macip.de Open-Source-Projekts.