Improving Bluesky Syndication: Notes, Images, and State

Category: Development

Tags: indieweb, bluesky


The first cut of my Bluesky syndication script shipped articles and nothing else. It ran, which was the good news — but the first real backfill exposed four things I had happily ignored: notes weren’t syndicated at all, large OG images blew past Bluesky’s blob size limit, same-day articles collapsed into a single visible post, and the state file tracked only that something had been posted, not where. This post walks through the fixes.

The setup

  • Site: Astro 6 static build, separate RSS feeds per content type (articles.xml, notes.xml, …).
  • Deploy: shell script that builds, rsyncs to the VPS, then runs bluesky-syndicate.mjs against the live feeds.
  • State: a JSON file in the repo root, committed back after each run.

Multiple content sources

Problem: the first version targeted a single RSS feed. Notes had their own feed from day one — nothing was reading it.

Implementation: I replaced the hard-coded URL with a SOURCES array that pairs each feed with a function producing the post text:

const SOURCES = [
  {
    rssUrl: "https://adrian-altner.de/rss/articles.xml",
    label: "article",
    postText: (item) => item.title,
  },
  {
    rssUrl: "https://adrian-altner.de/rss/notes.xml",
    label: "note",
    postText: (item) => item.description || item.title,
  },
];

Solution: articles go out with their title; notes go out with their description — notes don’t have titles that stand alone, so the description serves as the post body. Adding a new content type later is one object in this array.

State file: from array to record

Problem: the original state file was a flat array of GUIDs — enough to dedupe, not enough to link back to the syndicated copy on Bluesky.

["https://adrian-altner.de/articles/...", "..."]

Implementation: I switched the shape to an object mapping each GUID to its Bluesky post URL:

{
  "https://adrian-altner.de/articles/2026/03/23/joining-the-indieweb/": "https://bsky.app/profile/adrian-altner.de/post/3mhqb4t6ceu2w"
}

After a successful post, the script extracts the record key (rkey) from the AT Protocol URI and builds the public Bluesky URL:

const { uri } = await postRes.json();
const rkey = uri.split("/").pop();
posted[item.guid] = `https://bsky.app/profile/${IDENTIFIER}/post/${rkey}`;

Solution: the URL is now addressable from the rest of the build — specifically the u-syndication links rendered on each article and note page.

Image compression with Sharp

Problem: Bluesky’s blob upload limit is 976 KB. A photo-heavy note’s OG image came in at 1.56 MB and the upload failed hard with BlobTooLarge.

Implementation: compress the image before uploading:

import sharp from "sharp";

const compressed = await sharp(Buffer.from(rawBuffer))
  .resize(1200, 630, { fit: "cover", position: "centre" })
  .jpeg({ quality: 80 })
  .toBuffer();

Solution: 1200×630 is the standard OG ratio, and JPEG at quality 80 consistently lands under 200 KB — well below the limit, with no visible quality loss at Bluesky’s display size.

Publish dates, not deploy dates

Problem: the first version stamped every post with new Date().toISOString(). A batch of articles published weeks apart all appeared in the feed as posted today.

Implementation: read pubDate from the RSS item and add a per-item offset:

const baseDate = item.pubDate ? new Date(item.pubDate) : new Date();
const createdAt = new Date(baseDate.getTime() + i * 1000).toISOString();

The + i * 1000 is non-obvious but load-bearing: when multiple articles share a publish date, identical createdAt values cause Bluesky to deduplicate them — only one of them shows up. Adding one second per index keeps them distinct while preserving the intended date.

Persisting state after syndication

The state file lives in the repo root and is committed after each syndication run. The deploy script checks whether it changed:

if ! git diff --quiet .bluesky-posted.json; then
  git add .bluesky-posted.json
  git commit -m "chore: update bluesky posted state"
  git push
fi

Without the commit step, the file would reset on the next checkout and every article would be re-posted on the following deploy.

What to take away

  • Feeds as the input contract. A per-content-type feed plus a small SOURCES array makes adding a new type a one-line change.
  • State files earn their keep when you store URLs, not just GUIDs. The Bluesky post URL lets the rest of the build render u-syndication links automatically.
  • Compress before you upload. 1200×630 at JPEG quality 80 is a reliable fit for Bluesky’s 976 KB blob limit.
  • Use the item’s publish date plus an index offset. Real timestamps keep the timeline honest; the per-second offset prevents Bluesky’s same-timestamp dedup from eating batches.
  • Commit the state file in the deploy script. Otherwise you rediscover duplicate posting on the very next run.