---
title: 'Improving Bluesky Syndication: Notes, Images, and State'
description: Extending the syndication script to cover notes, compress OG images before upload, use original publish dates, and persist Bluesky post URLs in the state file.
pubDate: '2026-03-23T15:33:00+01:00'
category: en/development
tags:
  - indieweb
  - bluesky
seriesParent: joining-the-indieweb
seriesOrder: 7
---

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:

```js
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.

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

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

```json
{
  "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:

```js
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:

```js
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:

```js
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:

```bash
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.
