Beim Testen der Bluesky-Integration fiel auf: Manche Posts schlugen mit einer kryptischen Fehlermeldung fehl.
Invalid app.bsky.feed.post record:
Record/text must not be longer than 300 graphemes.
Das Problem: Bluesky zaehlt jeden Buchstaben der URL mit. Eine Blog-URL wie https://www.macip.de/blog/playwright-statt-api-browser-automatisierung-fuer-x-com-posts?utm_source=bluesky&utm_medium=social&utm_campaign=blog hat allein 144 Zeichen. Dazu kommen der Teaser-Text (bis zu 280 Zeichen) und zwei Zeilenumbrueche. Das ergibt bis zu 426 Zeichen — weit ueber dem Limit.
Bei Mastodon ist das kein Problem: URLs zaehlen dort pauschal als 23 Zeichen, egal wie lang sie tatsaechlich sind. Bei X.com funktioniert t.co als automatischer Shortener. Nur Bluesky zaehlt die volle Laenge.
Die Loesung: Ein eigener Link Shortener
Statt eines externen Dienstes wie Bitly oder TinyURL habe ich einen Self-Hosted Link Shortener direkt in macip.de integriert. Die Idee:
https://www.macip.de/s/a7Bx2kstatt der vollen URL (33 Zeichen statt 144)- Kein externer Dienst, keine Abhaengigkeit, keine Datenweitergabe
- Click-Tracking inklusive
- Fallback auf die lange URL, falls der Shortener ausfaellt
Das passt zur Grundphilosophie des Projekts: Alles self-hosted, alles unter eigener Kontrolle.
Architektur
Der Shortener besteht aus drei Teilen:
1. Datenbank-Tabelle short_links
Eine einfache Supabase-Tabelle speichert die Zuordnung:
CREATE TABLE short_links (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code text NOT NULL UNIQUE,
target_url text NOT NULL,
blog_post_id uuid REFERENCES blog_posts(id),
platform text NOT NULL, -- 'bluesky', 'mastodon', 'x'
click_count integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_short_links_post_platform
ON short_links (blog_post_id, platform);Der Unique-Index auf (blog_post_id, platform) stellt sicher, dass jeder Blog-Post pro Plattform genau einen Short-Link bekommt. UTM-Parameter bleiben plattformspezifisch — Bluesky bekommt utm_source=bluesky, Mastodon bekommt utm_source=mastodon.
2. Shortener-Modul
Das Modul in src/lib/shortener/ ist in zwei Dateien aufgeteilt:
core.ts— Die Kernlogik, ohneserver-only. Kann auch von PM2-Workern importiert werden.index.ts— Einserver-onlyWrapper fuer Next.js API-Routes.
export async function getOrCreateShortLink(
supabase: SupabaseClient,
blogPostId: string,
platform: string,
targetUrl: string,
): Promise<string> {
// 1. Bestehenden Link suchen
const { data: existing } = await supabase
.from("short_links")
.select("code, target_url")
.eq("blog_post_id", blogPostId)
.eq("platform", platform)
.single();
if (existing) {
// Target-URL aktualisieren falls Slug sich geaendert hat
if (existing.target_url !== targetUrl) {
await supabase
.from("short_links")
.update({ target_url: targetUrl })
.eq("blog_post_id", blogPostId)
.eq("platform", platform);
}
return `https://www.macip.de/s/${existing.code}`;
}
// 2. Neuen 6-Zeichen base62 Code generieren
const code = generateCode();
await supabase.from("short_links").insert({
code,
target_url: targetUrl,
blog_post_id: blogPostId,
platform,
});
return `https://www.macip.de/s/${code}`;
}Drei Design-Entscheidungen:
- Idempotent: Mehrfaches Aufrufen fuer denselben Post + Plattform gibt immer den gleichen Code zurueck.
- Slug-aenderungssicher: Wenn sich der Blog-Slug aendert, wird die
target_urlautomatisch aktualisiert — der Short-Code bleibt gleich. - Collision-sicher: Bei einer Code-Kollision (Unique-Constraint-Verletzung) wird bis zu 3x ein neuer Code generiert. Bei 62^6 = 56,8 Milliarden Kombinationen ist das allerdings eher theoretisch.
3. Redirect-Route
// src/app/s/[code]/route.ts
export async function GET(_req, { params }) {
const { code } = await params;
const { data } = await supabase
.from("short_links")
.select("target_url")
.eq("code", code)
.single();
if (!data) {
return NextResponse.json(
{ error: "Link nicht gefunden." },
{ status: 404 },
);
}
// Fire-and-forget Click-Counter
supabase.rpc("increment_click_count", { link_code: code }).then();
return NextResponse.redirect(data.target_url, 301);
}Der Click-Counter laeuft als Fire-and-forget — er blockiert den Redirect nicht. Die atomare Postgres-Funktion increment_click_count verhindert Race Conditions bei gleichzeitigen Requests.
Integration in die Publish-Routes
Der Shortener ist in alle drei Social-Media-Plattformen integriert:
// In publish-bsky/route.ts, publish-mastodon/route.ts, share-x/route.ts
const targetUrl = `${blogUrl}?utm_source=bluesky&utm_medium=social&utm_campaign=blog`;
let shortUrl: string;
try {
shortUrl = await createShortLink(id, "bluesky", targetUrl);
} catch {
shortUrl = targetUrl; // Fallback auf lange URL
}Das Fallback-Pattern ist entscheidend: Wenn der Shortener aus irgendeinem Grund fehlschlaegt, wird einfach die lange URL verwendet. Das Publizieren auf Social Media darf nie am Shortener scheitern.
Der gleiche Ansatz gilt auch fuer den Teaser-Worker, der die KI-generierten X.com-Teaser im Hintergrund verarbeitet. Dort wird core.ts direkt importiert (ohne server-only), weil der Worker ausserhalb des Next.js-Kontexts laeuft.
Vorher/Nachher
| Vorher | Nachher | |
|---|---|---|
| Bluesky-URL | 144 Zeichen | 33 Zeichen |
| Gesamtlaenge (280 + URL) | bis zu 426 Grapheme | bis zu 315 Grapheme |
| Bluesky-Limit (300) | 5 von 7 Posts zu lang | Alle Posts passen |
| Click-Tracking | Keins | Pro Plattform |
Kein Overkill?
Man koennte argumentieren, dass ein Link Shortener fuer ein paar Blog-Posts uebertrieben ist. Aber:
- Die gesamte Implementierung ist unter 100 Zeilen Code (ohne Types)
- Es loest ein echtes Problem (Bluesky-Posts schlagen fehl)
- Click-Tracking gibt es kostenlos dazu
- Es passt zur Architektur — die gleiche Supabase-Instanz, die auch das Blogsystem, die Kontaktanfragen und die Social-Media-Anbindung verwaltet
Und am Ende ist genau das der Vorteil von Self-Hosting: Wenn du die Infrastruktur kontrollierst, ist ein neues Feature oft nur eine Tabelle und eine Route entfernt.
Weiterlesen
- Bluesky-Integration: Blog-Artikel automatisch auf Bluesky posten — Die Integration, die den Shortener noetig gemacht hat
- Mastodon-Integration — Ein fetch() reicht — Die dritte Social-Media-Plattform, die vom Shortener profitiert
- Playwright statt API — Browser-Automatisierung fuer X.com-Posts — Warum X.com einen ganz anderen Ansatz braucht
- Ein Blogsystem von Grund auf — Das Fundament, auf dem alles aufbaut