Automating Multi‑Channel Content Metadata Sync in a Next.js Monorepo
TL;DR: I added a post‑publish hook that writes the newly generated Bluesky URIs and the Dev.to article URL back into the central metadata.json files. This keeps all channel metadata in sync automatically and eliminates manual copy‑paste errors.
The Problem
Our content‑automation pipeline generates Markdown for Medium, Substack and Bluesky, then publishes each piece via separate scripts. After each successful publish we need to store the resulting identifiers (Bluesky AT‑URIs, Dev.to article URL, etc.) so that later jobs—like newsletter generation or SEO audits—can reference them.
The metadata lives in content/<date>/<project>/metadata.json. Before this change the file was edited manually after each publish. The symptom was obvious:
$ cat content/2026/11/09/content-automation/metadata.json
...
"bluesky_uris": {
"es": [
"at://did:plc:y5omxao2h2bnmixo7sigikex/app.bsky.feed.post/3mzw7dtidwa2j",
"at://did:plc:y5omxao2h2bnmi"
]
},
"devto_url": ""
- The
devto_urlstayed empty because I forgot to paste it. - The
bluesky_uris.esarray sometimes contained duplicate URIs after a retry, causing downstream scripts to process the same post twice. - Every time a new channel was added we had to remember to edit the JSON manually, which is error‑prone and hard to audit.
What I Tried First
My first attempt was a quick fs.appendFile in the scripts/publish-bluesky.ts script:
// scripts/publish-bluesky.ts (initial version)
import { promises as fs } from 'fs';
import { publishToBluesky } from '../src/lib/bluesky';
async function main() {
const uri = await publishToBluesky('content/2026/11/09/content-automation/bluesky_en.json');
await fs.appendFile(
'content/2026/11/09/content-automation/metadata.json',
`"${uri}",\n`
);
}
main();
I ran the script twice (once for English, once for Spanish) and ended up with duplicated entries in the es array. The file also lost its proper JSON structure because I was appending raw text. The error manifested as a JSON parse failure in the next step of the pipeline:
SyntaxError: Unexpected token } in JSON at position 342
The Implementation
1. Define a Typed Metadata Model
I created a small TypeScript interface to describe the shape of metadata.json. This lives in src/types/metadata.ts:
// src/types/metadata.ts
export interface Metadata {
bluesky_published: boolean;
bluesky_uris: {
en: string[];
es: string[];
};
devto_url: string;
}
Having a type forces the compiler to catch structural mistakes early.
2. Idempotent Update Helper
The core of the solution is src/lib/metadata.ts. It reads the file, merges new URIs without duplication, and writes the file back atomically.
// src/lib/metadata.ts
import { promises as fs } from 'fs';
import { join } from 'path';
import { Metadata } from '../types/metadata';
const METADATA_ROOT = 'content/2026/11/09';
export async function updateMetadata(
project: string,
{
newBlueskyUri,
lang,
devtoUrl,
}: { newBlueskyUri?: string; lang?: 'en' | 'es'; devtoUrl?: string }
) {
const path = join(METADATA_ROOT, project, 'metadata.json');
const raw = await fs.readFile(path, 'utf-8');
const data: Metadata = JSON.parse(raw);
// Merge Bluesky URIs idempotently
if (newBlueskyUri && lang) {
const set = new Set(data.bluesky_uris[lang]);
set.add(newBlueskyUri);
data.bluesky_uris[lang] = Array.from(set);
data.bluesky_published = true;
}
// Update Dev.to URL once
if (devtoUrl) {
data.devto_url = devtoUrl;
}
// Write back with 2‑space indentation
await fs.writeFile(path, JSON.stringify(data, null, 2));
}
Key points:
- Set guarantees uniqueness, preventing the duplicate‑URI bug we saw earlier.
- The function is purely functional: it does not assume any global state.
- Writing with
JSON.stringify(..., null, 2)keeps the file human‑readable.
3. Hook Into Publish Scripts
Both the Bluesky and Dev.to publish scripts now call updateMetadata.
// scripts/publish-bluesky.ts
import { publishToBluesky } from '../src/lib/bluesky';
import { updateMetadata } from '../src/lib/metadata';
async function main() {
const lang: 'en' | 'es' = process.argv[2] as any;
const jsonPath = `content/2026/11/09/content-automation/bluesky_${lang}.json`;
const uri = await publishToBluesky(jsonPath);
await updateMetadata('content-automation', {
newBlueskyUri: uri,
lang,
});
}
main().catch(console.error);
ts
// scripts/publish-devto.ts
import { publishToDevto } from '../src/lib/devto';
import { updateMetadata } from '../src/lib/metadata';
async function main() {
const url = await publishToDevto('content/2026/11/09/content-automation/medium_en.md');
await updateMetadata('content-automation', { devtoUrl: url });
---
*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.*
*Repo: `zaerohell/content-automation` · 2026-11-10*
\#playadev #buildinpublic
Top comments (0)