Publish Once, Post Everywhere
Write once in Studio. Post everywhere. No copy-paste, no platform hopping.
schemaTypes/documents/socialPost.ts
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}
},
},
})
schemaTypes/components/characterCount.tsx
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>
)
}
sanity.blueprint.ts
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({
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,
}),
],
})functions/social-media-crosspost/index.ts
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
- Configure API access for your social platforms
- Add API credentials to your environment variables
- Set posting rules (immediate vs scheduled, platform selection)
- Configure formatting for each platform's requirements
How It Works
The function automatically:
- Triggers when content is published or updated
- Formats content for each platform's specifications
- Handles character limits and media requirements
- Posts to selected social channels via their APIs
- Tracks posting status and links back to published posts
Key Benefits
- Publish once, post everywhere - No more manual copying between platforms
- Consistent messaging - Same content across all channels
- Save hours weekly - Eliminate repetitive posting tasks
- Platform-optimized - Automatic formatting for each network's requirements
- Centralized workflow - Manage all social content from Sanity Studio
- Track performance - Know what posted where and when
Technical Implementation
The function uses:
- Event triggers on document publication
- Social platform APIs for posting
- Content transformation for platform requirements
- Media handling and optimization
- Status tracking and error handling
Perfect For
- Marketing teams managing multi-channel campaigns
- Content creators distributing across social platforms
- Social media managers streamlining posting workflows
- News organizations breaking stories across channels
- Brand teams maintaining consistent social presence
- Agencies managing multiple client accounts
For complete implementation details and API configuration guides, visit the GitHub repository.
Contributors

John Siciliano
I play with tech and talk about it

Ken Jones Pizza
Designer who spends most of his time coding