---
title: Syndication Links Without Frontmatter Editing
description: How .bluesky-posted.json auto-populates u-syndication links on every article and note page — no manual frontmatter required after each post.
pubDate: '2026-03-23T17:22:00+01:00'
category: en/development
tags:
  - indieweb
  - astro
seriesParent: joining-the-indieweb
seriesOrder: 5
---

The Microformats2 spec defines `u-syndication` — a link that marks where a post has been copied to. When a Bluesky post exists for an article, that link should appear on the article automatically. Editing frontmatter by hand after every deploy is not a workflow that scales past the second post. This one has a clean answer: the syndication state file is already the source of truth — let the build read it.

## The setup

- **Site**: Astro 6 static build, deployed via shell script.
- **Syndication state**: `.bluesky-posted.json` committed to the repo root — a map of canonical URL → Bluesky post URL.
- **Frontmatter**: a `syndication` array for manually added targets (e.g. a newsletter), nothing else.

## The problem with manual syndication

The obvious approach is to write the Bluesky URL into the article's frontmatter after it's been posted:

```yaml
syndication:
  - https://bsky.app/profile/adrian-altner.de/post/3mhqb4t6ceu2w
```

This works, but it requires a second commit after every syndication run: one to publish the article, one to add the Bluesky URL back into its source. The two sources of truth also drift — edit the article later and it's easy to forget the syndication line exists.

## The state file as the source of truth

**Implementation:** `.bluesky-posted.json` already has the information, and it's already committed after each run. Astro can import it directly at build time:

```ts
import bskyPosted from "../../../.bluesky-posted.json";
```

The file is a map of canonical URL → Bluesky post URL:

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

## Deriving syndication URLs at build time

On the article page, build the canonical URL first, then look up the Bluesky post URL from the imported JSON:

```ts
const articleUrl = new URL(`/articles/${post.id}/`, Astro.site).toString();
const bskyPostUrl =
  (bskyPosted as Record<string, string | null>)[articleUrl] ?? null;
const syndicationUrls: string[] = [
  ...(post.data.syndication ?? []),
  ...(bskyPostUrl ? [bskyPostUrl] : []),
];
```

`post.data.syndication` stays for manually added URLs — Mastodon, a newsletter, anything else. The Bluesky URL is appended only if it exists in the state file. The result is a unified list, no duplication.

Notes use the same pattern with `noteUrl` in place of `articleUrl`.

## Rendering the links

`syndicationUrls` drives the "Also on:" line below each post:

```astro
{syndicationUrls.length > 0 && (
  <p class="syndication-links">
    Also on:{" "}
    {syndicationUrls.map((url, i) => {
      const host = new URL(url).hostname.replace("www.", "");
      return (
        <>
          {i > 0 && ", "}
          <a class="u-syndication" href={url} rel="syndication noopener" target="_blank">
            {host}
          </a>
        </>
      );
    })}
  </p>
)}
```

The hostname is extracted from the URL for the link text — `bsky.app` labels Bluesky posts automatically. Adding another syndication target later would show its hostname without any template changes.

## Trailing slashes matter

**Problem:** webmention.io stores URLs exactly as submitted. If Bridgy sends a webmention for `https://adrian-altner.de/articles/.../hello-world/` (with trailing slash), the webmention component has to query with the same URL.

**Implementation:** Astro's URL construction with a trailing slash in the template literal keeps this consistent:

```ts
const articleUrl = new URL(`/articles/${post.id}/`, Astro.site).toString();
// → "https://adrian-altner.de/articles/2025/12/01/hello-world/"
```

**Solution:** lookup keys in `.bluesky-posted.json` and query strings sent to webmention.io are byte-for-byte identical. Without the trailing slash, a lookup for `/hello-world` returns zero results even when mentions exist — and you spend half an hour debugging "webmentions broken".

## No frontmatter edits required

The full flow: deploy → syndication script posts to Bluesky → state file updated → state file committed and pushed → next build reads state → syndication links appear on every page that has a Bluesky post.

Article frontmatter is touched exactly once: at initial publish.

## What to take away

- **Let the syndication state file be the source of truth.** Frontmatter editing doesn't scale, and it desyncs silently.
- **Import JSON directly into Astro components.** Build-time imports are type-safe and don't cost anything at runtime.
- **Merge with `post.data.syndication`.** Manual entries and auto-derived URLs should flow through the same array.
- **Normalise on trailing slashes everywhere.** Canonical URL in the lookup, in webmention.io, in the sitemap — one format, no exceptions.
- **Render hostnames, not URLs.** The list stays readable and new targets slot in without template changes.
