John Siciliano
I play with tech and talk about it
Write once in Studio. Post everywhere. No copy-paste, no platform hopping.
import {defineField, defineType} from 'sanity'
import {CharacterCount} from '../../components/characterCount'
export const platformConfig = {
x: {limit: 280, label: 'X'},
mastodon: {limit: 500, label: 'Mastodon'},
bluesky: {limit: 300, label: 'Bluesky'},
linkedin: {limit: 3000, label: 'LinkedIn'},
discord: {limit: 2000, label: 'Discord'},
telegram: {limit: 4096, label: 'Telegram'},
slack: {limit: 4000, label: 'Slack'},
devto: {limit: 10_000, label: 'Dev.to'},
} as const
export type Platform = keyof typeof platformConfig
const platformOptions = (Object.keys(platformConfig) as Array<Platform>).map((k) => ({
title: platformConfig[k].label,
value: k,
}))
export const socialPost = defineType({
name: 'socialPost',
title: 'Social Post',
type: 'document',
fields: [
defineField({
name: 'platforms',
title: 'Platforms',
type: 'array',
of: [{type: 'string'}],
readOnly: ({document}) => Boolean(document?.status),
options: {
list: platformOptions,
layout: 'grid',
},
validation: (Rule) => Rule.required().min(1),
}),
defineField({
name: 'body',
title: 'Body',
type: 'text',
rows: 5,
readOnly: ({document}) => Boolean(document?.status),
description: 'The global body of the post. It can be overridden by platform below.',
validation: (Rule) =>
Rule.required().custom((body, context) => {
const document = context.document as any
const selectedPlatforms = document?.platforms as Platform[] | undefined
const overriddenSettings = document?.platformOverrides
if (!document || !body || !Array.isArray(selectedPlatforms)) {
return true
}
const errors = selectedPlatforms
.filter(
(platform) =>
!overriddenSettings?.some(
(setting: any) => setting.platform === platform && setting.body?.trim(),
),
)
.reduce<string[]>((acc, platform) => {
const {limit, label} = platformConfig[platform]
if (body.length > limit) {
acc.push(`${label} body cannot be longer than ${limit} characters.`)
}
return acc
}, [])
return errors.length > 0 ? errors.join(' ') : true
}),
}),
defineField({
name: 'characterCount',
title: 'Character Count',
type: 'string',
components: {
field: CharacterCount,
},
readOnly: true,
}),
defineField({
name: 'mainImage',
title: 'Image',
type: 'image',
readOnly: ({document}) => Boolean(document?.status),
options: {
hotspot: true,
},
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative text',
description: 'Important for accessibility and SEO.',
},
],
}),
defineField({
name: 'platformOverrides',
title: 'Platform Overrides',
type: 'array',
readOnly: ({document}) => Boolean(document?.status),
of: [
{
type: 'object',
fields: [
defineField({
name: 'platform',
title: 'Platform',
type: 'string',
options: {
list: platformOptions,
},
validation: (Rule) =>
Rule.required().custom((value, context) => {
const document = context.document as any
const selected = Array.isArray(document?.platforms) ? document.platforms : []
if (!value) return true
return selected.includes(value)
? true
: "Pick a platform that's selected in the Platforms field."
}),
}),
defineField({
name: 'body',
title: 'Body',
type: 'text',
validation: (Rule) =>
Rule.custom((body, context) => {
const {document} = context
if (!document || !body) {
return true
}
const override = context.parent as {platform: Platform}
const {limit, label} = platformConfig[override.platform]
if (body.length > limit) {
return `${label} body cannot be longer than ${limit} characters.`
}
return true
}),
}),
defineField({
name: 'characterCount',
title: 'Character Count',
type: 'string',
components: {
field: CharacterCount,
},
readOnly: true,
}),
],
},
],
}),
defineField({
name: 'status',
title: 'Status',
description:
'The state of each request. Links to the published posts if successful or the error message.',
type: 'array',
of: [{type: 'string'}],
readOnly: true,
}),
],
preview: {
select: {
title: 'body',
subtitle: 'platforms',
},
prepare(selection) {
const {title: body, subtitle: platforms} = selection
const title = body || 'Untitled Post'
const subtitle = platforms
? platforms
.map((platform: keyof typeof platformConfig) => platformConfig[platform].label)
.sort()
.join(', ')
: ''
return {title, subtitle}
},
},
})
import {useFormValue} from 'sanity'
import {Badge, Card, Flex, Stack, Text} from '@sanity/ui'
import {platformConfig, type Platform} from '../schemaTypes/documents/socialPost'
export function CharacterCount(props: any) {
const doc = useFormValue([]) as any
const parentPath = props.path.slice(0, -1)
const parentValue = useFormValue(parentPath) as {platform?: Platform}
// Now we can do conditional logic
if (!doc.platforms || doc.platforms.length === 0) {
return null
}
const platformFilter = parentValue?.platform
const platformsToDisplay = platformFilter
? doc.platforms.filter((p: Platform) => p === platformFilter)
: doc.platforms
if (platformsToDisplay.length === 0) {
return null
}
return (
<Card shadow={1} padding={3} radius={2}>
<Stack space={3}>
<Flex gap={3} direction="row" align="center" wrap="wrap">
{platformsToDisplay.map((platform: Platform) => {
const platformSetting = Array.isArray(doc.platformOverrides)
? doc.platformOverrides.find((setting: any) => setting.platform === platform)
: undefined
const text = platformSetting?.body ?? doc.body ?? ''
const limit = platformConfig[platform].limit
const label = platformConfig[platform].label
const isOverLimit = text.length > limit
return (
<Flex key={platform} gap={2} direction="row" align="center">
<Text size={1} weight="medium">
{label}
</Text>
<Badge tone={isOverLimit ? 'critical' : 'default'}>
{text.length}/{limit}
</Badge>
{platformSetting?.body && <Badge tone="primary">Overridden</Badge>}
</Flex>
)
})}
</Flex>
</Stack>
</Card>
)
}
import 'dotenv/config'
import process from 'node:process'
import {defineBlueprint, defineDocumentFunction} from '@sanity/blueprints'
// Extract environment variables for platforms you want to use
const {
TWITTER_ACCESS_TOKEN_KEY,
TWITTER_ACCESS_TOKEN_SECRET,
TWITTER_API_CONSUMER_KEY,
TWITTER_API_CONSUMER_SECRET,
MASTODON_ACCESS_TOKEN,
MASTODON_HOST,
BLUESKY_IDENTIFIER,
BLUESKY_PASSWORD,
BLUESKY_HOST,
LINKEDIN_ACCESS_TOKEN,
DISCORD_WEBHOOK_URL,
TELEGRAM_BOT_TOKEN,
TELEGRAM_CHAT_ID,
SLACK_BOT_TOKEN,
SLACK_CHANNEL,
DEVTO_API_KEY,
} = process.env
// Ensure environment variables are strings or provide defaults
const crosspostEnvVars = {
TWITTER_ACCESS_TOKEN_KEY: TWITTER_ACCESS_TOKEN_KEY || '',
TWITTER_ACCESS_TOKEN_SECRET: TWITTER_ACCESS_TOKEN_SECRET || '',
TWITTER_API_CONSUMER_KEY: TWITTER_API_CONSUMER_KEY || '',
TWITTER_API_CONSUMER_SECRET: TWITTER_API_CONSUMER_SECRET || '',
MASTODON_ACCESS_TOKEN: MASTODON_ACCESS_TOKEN || '',
MASTODON_HOST: MASTODON_HOST || '',
BLUESKY_IDENTIFIER: BLUESKY_IDENTIFIER || '',
BLUESKY_PASSWORD: BLUESKY_PASSWORD || '',
BLUESKY_HOST: BLUESKY_HOST || '',
LINKEDIN_ACCESS_TOKEN: LINKEDIN_ACCESS_TOKEN || '',
DISCORD_WEBHOOK_URL: DISCORD_WEBHOOK_URL || '',
TELEGRAM_BOT_TOKEN: TELEGRAM_BOT_TOKEN || '',
TELEGRAM_CHAT_ID: TELEGRAM_CHAT_ID || '',
SLACK_BOT_TOKEN: SLACK_BOT_TOKEN || '',
SLACK_CHANNEL: SLACK_CHANNEL || '',
DEVTO_API_KEY: DEVTO_API_KEY || '',
}
export default defineBlueprint({
resources: [
defineDocumentFunction({
type: 'sanity.function.document',
name: 'social-media-crosspost',
src: './functions/social-media-crosspost',
memory: 2,
timeout: 30,
event: {
on: ['create'],
filter: "_type == 'socialPost'",
projection: '{_id, body, mainImage, platforms, platformOverrides}',
},
env: crosspostEnvVars,
}),
],
})import {env} from 'node:process'
import {
BlueskyStrategy,
Client,
DevtoStrategy,
DiscordWebhookStrategy,
LinkedInStrategy,
MastodonStrategy,
SlackStrategy,
TelegramStrategy,
TwitterStrategy,
type Strategy,
} from '@humanwhocodes/crosspost'
import {createClient} from '@sanity/client'
import {documentEventHandler} from '@sanity/functions'
import imageUrlBuilder from '@sanity/image-url'
// Crosspost response types (not exported by the library)
type CrosspostSuccessResponse = {ok: true; url: string}
type CrosspostFailureResponse = {ok: false; reason: unknown}
type CrosspostResponse = CrosspostSuccessResponse | CrosspostFailureResponse
// Event data type matching the blueprint projection
type EventData = {
_id: string
body?: string
mainImage?: {
asset: {
_ref: string
_type: 'reference'
}
alt?: string
}
platforms?: string[]
platformOverrides?: Array<{
platform?: string
body?: string
}>
}
type Platform = string
type StrategyConfig = {
strategy: Strategy
bodyOverride: string | undefined
}
// Map our platform keys to their corresponding strategy IDs
const PLATFORM_TO_STRATEGY_ID: Record<string, string> = {
x: 'twitter',
discord: 'discord-webhook',
}
const {
TWITTER_ACCESS_TOKEN_KEY,
TWITTER_ACCESS_TOKEN_SECRET,
TWITTER_API_CONSUMER_KEY,
TWITTER_API_CONSUMER_SECRET,
MASTODON_ACCESS_TOKEN,
MASTODON_HOST,
BLUESKY_IDENTIFIER,
BLUESKY_PASSWORD,
BLUESKY_HOST,
LINKEDIN_ACCESS_TOKEN,
DISCORD_WEBHOOK_URL,
TELEGRAM_BOT_TOKEN,
TELEGRAM_CHAT_ID,
SLACK_BOT_TOKEN,
SLACK_CHANNEL,
DEVTO_API_KEY,
} = env
const getImageData = async (
image: EventData['mainImage'],
client: ReturnType<typeof createClient>,
) => {
if (!image?.asset) {
return undefined
}
const builder = imageUrlBuilder(client)
const imageUrl = builder.image(image).width(1024).quality(75).url()
const response = await fetch(imageUrl)
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.statusText}`)
}
return new Uint8Array(await response.arrayBuffer())
}
const getBody = (
platform: Platform,
platformOverrides: EventData['platformOverrides'],
): string | undefined => {
const setting = platformOverrides?.find((item) => item.platform === platform)
return setting?.body?.trim()
}
const createStrategies = (
platforms: Platform[],
platformOverrides: EventData['platformOverrides'],
): StrategyConfig[] => {
const strategyConfigs: StrategyConfig[] = []
if (
platforms.includes('x') &&
TWITTER_ACCESS_TOKEN_KEY &&
TWITTER_ACCESS_TOKEN_SECRET &&
TWITTER_API_CONSUMER_KEY &&
TWITTER_API_CONSUMER_SECRET
) {
strategyConfigs.push({
strategy: new TwitterStrategy({
accessTokenKey: TWITTER_ACCESS_TOKEN_KEY,
accessTokenSecret: TWITTER_ACCESS_TOKEN_SECRET,
apiConsumerKey: TWITTER_API_CONSUMER_KEY,
apiConsumerSecret: TWITTER_API_CONSUMER_SECRET,
}),
bodyOverride: getBody('x', platformOverrides),
})
}
if (platforms.includes('mastodon') && MASTODON_ACCESS_TOKEN && MASTODON_HOST) {
strategyConfigs.push({
strategy: new MastodonStrategy({
accessToken: MASTODON_ACCESS_TOKEN,
host: MASTODON_HOST,
}),
bodyOverride: getBody('mastodon', platformOverrides),
})
}
if (platforms.includes('bluesky') && BLUESKY_IDENTIFIER && BLUESKY_PASSWORD && BLUESKY_HOST) {
strategyConfigs.push({
strategy: new BlueskyStrategy({
identifier: BLUESKY_IDENTIFIER,
password: BLUESKY_PASSWORD,
host: BLUESKY_HOST,
}),
bodyOverride: getBody('bluesky', platformOverrides),
})
}
if (platforms.includes('linkedin') && LINKEDIN_ACCESS_TOKEN) {
strategyConfigs.push({
strategy: new LinkedInStrategy({
accessToken: LINKEDIN_ACCESS_TOKEN,
}),
bodyOverride: getBody('linkedin', platformOverrides),
})
}
if (platforms.includes('discord') && DISCORD_WEBHOOK_URL) {
strategyConfigs.push({
strategy: new DiscordWebhookStrategy({
webhookUrl: DISCORD_WEBHOOK_URL,
}),
bodyOverride: getBody('discord', platformOverrides),
})
}
if (platforms.includes('telegram') && TELEGRAM_BOT_TOKEN && TELEGRAM_CHAT_ID) {
strategyConfigs.push({
strategy: new TelegramStrategy({
botToken: TELEGRAM_BOT_TOKEN,
chatId: TELEGRAM_CHAT_ID,
}),
bodyOverride: getBody('telegram', platformOverrides),
})
}
if (platforms.includes('slack') && SLACK_BOT_TOKEN && SLACK_CHANNEL) {
strategyConfigs.push({
strategy: new SlackStrategy({
botToken: SLACK_BOT_TOKEN,
channel: SLACK_CHANNEL,
}),
bodyOverride: getBody('slack', platformOverrides),
})
}
if (platforms.includes('devto') && DEVTO_API_KEY) {
strategyConfigs.push({
strategy: new DevtoStrategy({
apiKey: DEVTO_API_KEY,
}),
bodyOverride: getBody('devto', platformOverrides),
})
}
return strategyConfigs
}
export const handler = documentEventHandler(async ({context, event}) => {
const {local} = context // local is true when running locally
console.log('Starting crosspost...')
if (local) {
console.log(
'⚠️ Running in local development mode - posts will be sent to platforms but status updates to Sanity documents will be skipped',
)
}
const {_id, mainImage, platforms, platformOverrides, body} = event.data as EventData
if (!platforms?.length) {
console.warn('No platforms were selected. Skipping.')
return
}
if (!body) {
console.warn('No body was found. Skipping.')
return
}
const sanityClient = createClient({
...context.clientOptions,
apiVersion: '2025-11-05',
useCdn: false,
})
try {
const strategyConfigs = createStrategies(platforms, platformOverrides)
if (strategyConfigs.length === 0) {
console.warn('No enabled strategies found. Check your environment variables.')
return
}
console.log(
`Found ${strategyConfigs.length} enabled strategies: ${strategyConfigs.map((config) => config.strategy.name).join(', ')}`,
)
// Identify platforms that were selected but don't have credentials configured
const enabledPlatformIds = strategyConfigs.map((config) => config.strategy.id)
const skippedPlatforms = platforms.filter((platform) => {
const strategyId = PLATFORM_TO_STRATEGY_ID[platform] || platform
return !enabledPlatformIds.includes(strategyId)
})
if (skippedPlatforms.length > 0) {
console.warn(
`Skipping ${skippedPlatforms.length} platforms due to missing credentials: ${skippedPlatforms.join(', ')}`,
)
}
// Support single image attachment across all platforms
let imageArg: {data: Uint8Array; alt: string} | undefined = undefined
if (mainImage) {
const imageData = await getImageData(mainImage, sanityClient)
if (imageData) {
imageArg = {
data: imageData,
alt: mainImage.alt ?? '',
}
}
}
// Set all statuses to pending (skip in local development mode)
if (!local) {
const statuses = [
...strategyConfigs.map((config) => `Posting to ${config.strategy.name}...`),
...skippedPlatforms.map((platform) => `Skipped ${platform}: No credentials configured`),
]
await sanityClient.patch(_id).set({status: statuses}).commit()
}
const postPromises = strategyConfigs.map((strategyConfig, index) => {
const strategyName = strategyConfig.strategy.name
return (async () => {
let resultMessage: string
try {
const singleStrategyClient = new Client({
strategies: [strategyConfig.strategy],
})
const responses = (await singleStrategyClient.post(strategyConfig.bodyOverride ?? body, {
images: imageArg ? [imageArg] : undefined,
})) as CrosspostResponse[]
const response = responses[0]
if (response.ok) {
const successMessage = `✅ ${response.url}`
console.log(successMessage)
resultMessage = successMessage
} else {
const error = response.reason?.toString() || 'Unknown error'
resultMessage = `⚠️ Error posting to ${strategyName}: ${error}`
console.error(resultMessage)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
resultMessage = `⚠️ Error posting to ${strategyName}: ${errorMessage}`
console.error(resultMessage)
}
// Update the status for this specific post (skip in local development mode)
if (!local) {
await sanityClient.patch(_id).splice('status', index, 1, [resultMessage]).commit()
}
})()
})
await Promise.all(postPromises)
} catch (error) {
console.error('Error during crosspost:', error)
}
})
The Problem:
Publishing content to multiple social platforms is tedious and time-consuming. You write once in Sanity, then manually copy, reformat, and post to each platform—risking inconsistencies, delays, and human error. Marketing teams waste hours on repetitive posting tasks.
The Solution:
This function automatically crossposts your Sanity content to multiple social media platforms. Publish once in Studio, and watch your content flow to all your social channels with platform-specific formatting—no copying, no context-switching, no manual posting.
Quick Start
View full instructions and source code at github.com/sanity-io/sanity/tree/main/examples/functions/social-media-crosspost
Initialize blueprints if you haven't already:
npx sanity blueprints init
Add the function to your project:
npx sanity blueprints add function --example social-media-crosspost
Deploy to production:
npx sanity blueprints deploy
Social Platform Setup
More detailed instructions in the GitHub README
How It Works
The function automatically:
Key Benefits
Technical Implementation
The function uses:
Perfect For
For complete implementation details and API configuration guides, visit the GitHub repository.
I play with tech and talk about it
Designer who spends most of his time coding
Automatically track when content was first published with a timestamp that sets once and never overwrites, providing reliable publication history for analytics and editorial workflows.
Go to First Published Timestamp FunctionAI-powered automatic tagging for Sanity blog posts that analyzes content to generate 3 relevant tags, maintaining consistency by reusing existing tags from your content library.
Go to Automatically tag blog postsOn document publish, send a Slack notification so your team is informed
Go to Notify your team via Slack on publishCreate, preview, and send Klaviyo campaigns without ever leaving Sanity Studio"
Go to Klaviyo campaigns without leaving your Studio