← Zurueck zum Blog
Link-ShortenerBlueskySupabaseNext.jsTypeScriptSelf-HostedSocial-Media

Self-Hosted Link Shortener — Warum Bluesky keine langen URLs mag

Marco Carstensen·9. März 2026·5 Min. Lesezeit

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/a7Bx2k statt 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, ohne server-only. Kann auch von PM2-Workern importiert werden.
  • index.ts — Ein server-only Wrapper 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_url automatisch 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

VorherNachher
Bluesky-URL144 Zeichen33 Zeichen
Gesamtlaenge (280 + URL)bis zu 426 Graphemebis zu 315 Grapheme
Bluesky-Limit (300)5 von 7 Posts zu langAlle Posts passen
Click-TrackingKeinsPro Plattform

Kein Overkill?

Man koennte argumentieren, dass ein Link Shortener fuer ein paar Blog-Posts uebertrieben ist. Aber:

  1. Die gesamte Implementierung ist unter 100 Zeilen Code (ohne Types)
  2. Es loest ein echtes Problem (Bluesky-Posts schlagen fehl)
  3. Click-Tracking gibt es kostenlos dazu
  4. 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