Publish Once, Post Everywhere

Official(made by Sanity team)

By John Siciliano & Ken Jones Pizza

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({
         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,
       }),
     ],
   })

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

  1. Configure API access for your social platforms
  2. Add API credentials to your environment variables
  3. Set posting rules (immediate vs scheduled, platform selection)
  4. 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

Official Recipes by Sanity

First Published Timestamp Function

Featured contribution
Official(made by Sanity team)

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.

Knut Melvær
Go to First Published Timestamp Function

Automatically tag blog posts

Featured contribution
Official(made by Sanity team)

AI-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 posts