DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Multi‑Platform Content Publishing with JSON‑Driven Metadata (Bluesky, Dev.to, Substack)

Automating Multi‑Platform Content Publishing with JSON‑Driven Metadata (Bluesky, Dev.to, Substack)

TL;DR: I extended the content-automation repo to generate and push Spanish‑language posts to Bluesky directly from our content folder, wiring the new bluesky_es.json payload into the existing publishing pipeline. This change centralizes platform‑specific metadata, eliminates manual copy‑pasting, and makes the process repeatable for any new language.


The Problem

Our weekly content workflow required three manual steps for each article:

  1. Write the markdown files for Medium, Substack, and Dev.to.
  2. Copy the same headline, excerpt, and tags into each platform’s UI.
  3. Manually track which posts were already published in a spreadsheet.

The biggest pain point was the Bluesky integration. We only had an English payload (bluesky_en.json) and the dashboard for the Broker portal kept throwing a “setToken is undefined” runtime error after login, causing the session not to refresh. The symptom manifested as a stale token in the request headers, and the API rejected subsequent calls with 401 Unauthorized. Because the metadata for Bluesky was hard‑coded in the CI script, any fix required editing the script and re‑running the pipeline, which broke the build‑in‑public cadence.


What I Tried First

My first attempt was to hard‑code the Spanish payload directly inside the CI job:

# .github/workflows/publish.yml (initial hack)
run: |
  curl -X POST https://bsky.social/api/v1/post \
    -H "Authorization: Bearer ${{ secrets.BLUESKY_TOKEN }}" \
    -d '{"text":"¡Nuevo post!","langs":["es"]}'
Enter fullscreen mode Exit fullscreen mode

This worked for a single run, but it introduced several problems:

  • The JSON was duplicated across the repo, violating DRY.
  • Any change to the post body required editing the workflow file, which is noisy in the commit history.
  • The script still failed when the setToken bug resurfaced because the token refresh logic lived in portal-broker/page.tsx, not in the CI step.

The approach was discarded in favor of a data‑driven solution that lives alongside the other content files.


The Implementation

1. Add a language‑specific Bluesky payload

I created content/2026/08/09/VS/bluesky_es.json. The file mirrors the structure used for English but contains the Spanish text and a reference to the bug fix commit.

[
  {
    "type": "avance",
    "text": "Finalmente resolví el bug en setToken dentro de portal-broker/page.tsx. El dashboard de Broker ya refresca la sesión después del login, gr",
    "langs": ["es"]
  }
]
Enter fullscreen mode Exit fullscreen mode

Only the text field is required for the Bluesky API; the type field is a custom tag we use for internal analytics.

2. Update the platform metadata

The central metadata.json files now expose a bluesky_published flag and a bluesky_uris map. The diff shows the change:

@@ -19,7 +19,14 @@
   "closed_issues": 0,
   "medium_generated": false,
   "substack_generated": false,
-  "bluesky_published": false,
-  "bluesky_uris": {},
+  "bluesky_published": true,
+  "bluesky_uris": {
+    "es": [],
+    "en": []
+  },
Enter fullscreen mode Exit fullscreen mode

In content/2026/08/09/content-automation/metadata.json the same map was initialized:

@@ -15,6 +15,9 @@
   "medium_generated": false,
   "substack_generated": false,
   "bluesky_published": false,
-  "bluesky_uris": {},
+  "bluesky_uris": {
+    "es": [],
+    "en": []
+  },
Enter fullscreen mode Exit fullscreen mode

Setting bluesky_published to true tells the CI step that this entry should be processed, while the empty arrays will be populated with the resulting URIs after a successful post.

3. Extend the publishing script

The scripts/publish.ts (TypeScript) now reads any bluesky_*.json file found under the date folder and builds the request payload dynamically.

// scripts/publish.ts
import fs from 'fs';
import path from 'path';
import fetch from 'node-fetch';

async function publishBluesky(lang: string, dateFolder: string) {
  const payloadPath = path.join(dateFolder, `bluesky_${lang}.json`);
  if (!fs.existsSync(payloadPath)) return;

  const posts = JSON.parse(fs.readFileSync(payloadPath, 'utf-8'));
  for (const post of posts) {
    const response = await fetch('https://bsky.social/xrpc/com.atproto.repo.createRecord', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.BLUESKY_TOKEN}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        repo: process.env.BLUESKY_REPO,
        collection: 'app.bsky.feed.post',
        record: {
          text: post.text,
          langs: post.langs,
        },
      }),
    });

    if (!response.ok) {
      const err = await response.text();
      console.error(`Bluesky publish failed: ${err}`);
      continue;
    }

    const result = await response.json();
    // Store the URI back into metadata
    const metaPath = path.join(dateFolder, 'metadata.json');
    const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
    meta.bluesky_uris[lang].push(result.uri);
    fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
  }
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Dynamic language detection – the function receives lang ('es' or 'en') and looks for the matching JSON file.
  • Error handling – logs the raw API response on failure, which helped us spot the 401 token issue earlier.
  • Metadata round‑trip – after each successful post, we push the returned URI back into metadata.json. This creates a single source of truth for all platforms.

The CI workflow (.github/workflows/publish.yml) now invokes the script for each language:

- name: Publish to Bluesky (ES)
  run: node scripts/publish.js --lang es --date ${{ env.CONTENT_DATE }}

- name: Publish to Bluesky (EN)
  run: node scripts/publish.js --lang en --date ${{ env.CONTENT_DATE }}
Enter fullscreen mode Exit fullscreen mode

4. Wire the token refresh fix

The bug that triggered this whole effort lived in portal-broker/page.tsx. The fix was a simple guard around the token setter:

// portal-broker/page.tsx (excerpt)
useEffect(() => {
  if (authResult?.token) {
    // Previously we called setToken(undefined) on every render
    setToken(authResult.token);
  }
}, [authResult?.token]);
Enter fullscreen mode Exit fullscreen mode

After the change, the dashboard correctly refreshed the session, and the subsequent Bluesky API calls now include a valid Authorization header. The bluesky_es.json entry references this fix in its text field, providing context for readers.


Key Takeaway

Separate content data from the publishing logic. By storing platform‑specific payloads in language‑named JSON files and letting a generic script consume them, we eliminated duplicated code, made the pipeline idempotent, and gained a single source of truth for post URIs. This pattern scales to any number of platforms or languages with minimal friction.


What's Next

  • Add a validation step that lints each bluesky_*.json against the Bluesky API schema before the CI run, catching malformed payloads early.
  • Introduce a retry queue for transient network errors, persisting failed posts in a bluesky_retry.json file.
  • Expose a small GraphQL endpoint

Part of my Build in Public series — sharing the real process of building SaaS projects from Playa del Carmen, México.

Repo: zaerohell/content-automation · 2026-08-10

#playadev #buildinpublic

Top comments (0)