Publisher adapter pattern
Problem
Cross-post to N platforms (Bluesky, Mastodon, Threads, Facebook, Instagram, Ghost, Hashnode, Dev.to) from one admin form. Each platform:
- Different auth (Bearer JWT, HMAC signed JWT, app password, session
cookie, GraphQL PAT,
api-keyheader, ...) - Different endpoint style (JSON REST, form-encoded, GraphQL, multi-step container → publish)
- Different limits (300 chars, 500 chars, unlimited)
- Different metadata (tags array vs
{name, slug}array, canonical URL field name, cover image location)
Pattern
One adapter file per platform, all exporting the exact same signature:
type Adapter = (
input: ShortPostInput | ArticleInput,
ctx: PublisherContext,
) => Promise<PublishResult>
PublisherContext carries the resolved secrets (as a keyed record) and a
free-form options record for per-request knobs (identifier, pageId,
adminUrl, ...).
A small router in lib/publishers/index.ts picks the right adapter:
async function runShortPublisher(platform, input, ctx) {
switch (platform) {
case 'bluesky': return publishToBluesky(input, ctx)
case 'mastodon': return publishToMastodon(input, ctx)
...
}
}
Benefits
- No SDKs. Every adapter is
fetch()+URLSearchParams/ JSON. When Meta bumpsv20.0→v21.0, one string moves. - Adding a platform = one new file + one entry in
meta-catalog.ts. - Testable in isolation — pass a fake
ctx.secrets, assert the request payload. - Client UI and server action share
PUBLISHERSmetadata throughmeta-catalog.ts(client-safe), while the actual adapters stayserver-only(they touch tokens).
Common gotchas
- IG / Threads / FB Graph = two-step:
POST /mediareturns a container ID, thenPOST /media_publishpromotes it. If you skip step 2, nothing appears. - Ghost Admin API wants an HS256 JWT with the hex-decoded secret
as the key,
aud: '/admin/', 5-min TTL.kidheader = the id half. - Bluesky app URL uses the handle, not the DID:
https://bsky.app/profile/{handle}/post/{rkey}.rkey= last segment of theat://URI. - Hashnode GraphQL mutation
publishPostneedspublicationId, and tag slugs must be lowercase, unicode-stripped. Use the same slugify you use elsewhere or Hashnode silently drops them. - Dev.to tags must be
[a-z0-9]only and max 4 items — no dashes, no spaces. Sanitise or the whole request 422s.