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:
- Write the markdown files for Medium, Substack, and Dev.to.
- Copy the same headline, excerpt, and tags into each platform’s UI.
- 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"]}'
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
setTokenbug resurfaced because the token refresh logic lived inportal-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"]
}
]
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": []
+ },
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": []
+ },
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));
}
}
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
401token 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 }}
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]);
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_*.jsonagainst 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.jsonfile. - 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)