Create Social Image in Sanity Studio
Last time, I posted about how to create social image using Puppeteer. If you haven't read it, I recommend you to give it a read. Now in this post, I'm going to put things together in your Sanity studio.
In this article, I assume you have a mini webpage like https://eunjae-stuff.vercel.app/blog-image.html?title=Hello&description=world. If not, read the previous post. And I host my Sanity studio in Vercel and use its serverless function, but it won't be too much different if you're using Netlify (Just a little bit here and there).
Let's create api/create-image.js
.
api/create-image.js
const handler = async (req, res) => {const { id, title, description } = req.bodyconst filePath = await createImage(title, description)await uploadToSanity(id, filePath)res.status(200)}export default handler
So here's what we're going to do. We will create an image with a title and a description. The image will be stored as a file somewhere temporarily. We upload the image to Sanity server and set the id of the asset to my document (blog post).
1. createImage()
It's no different from what was explained in the previous post.
import chrome from 'chrome-aws-lambda'import puppeteer from 'puppeteer-core'import tempfile from 'tempfile'async function createImage(title, description) {const browser = await puppeteer.launch({defaultViewport: null,args: [...chrome.args, `--window-size=2560,1440`],executablePath: await chrome.executablePath,headless: chrome.headless,})const page = await browser.newPage()page.goto(`https://eunjae-stuff.vercel.app/blog-image.html?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}`)await page.waitForFunction(`window.done === true`)const filePath = tempfile('.png')await page.screenshot({path: filePath,clip: { x: 0, y: 0, width: 1200, height: 628 },})await browser.close()return filePath}
tempfile is a library that returns a path of a temporary file. It opens up https://eunjae-stuff.vercel.app/blog-image.html?... and takes a screenshot of it. Nothing special here.
2. uploadToSanity()
1// eslint-disable-next-line import/no-extraneous-dependencies2import sanityClient from '@sanity/client'3import { basename } from 'path'4import { createReadStream } from 'fs'5import config from '../sanity.json'67async function uploadToSanity(id, filePath) {8 const client = sanityClient({9 projectId: config.api.projectId,10 dataset: config.api.dataset,11 token: process.env.SANITY_STUDIO_WRITE_TOKEN,12 useCdn: false,13 })1415 await client.assets16 .upload('image', createReadStream(filePath), {17 filename: basename(filePath),18 })19 .then(imageAsset => {20 return client21 .patch(id)22 .set({23 metaImage: {24 _type: 'image',25 asset: {26 _type: 'reference',27 _ref: imageAsset._id,28 },29 },30 })31 .commit()32 })33}
We need an environment variable SANITY_STUDIO_WRITE_TOKEN
. Make sure you get a "write" token and set it to your serverless function host like Vercel or Netlify, (or, .env if testing locally).
As you can see above, I have metaImage
field in my post
schema.
{name: 'metaImage',type: 'image',title: 'Meta Image',},
On the line 16, it uploads the image to Sanity. Once it's done, on the line 22, it updates the document with the asset id.
Who is calling this API?
Okay then, who is calling this API? Sanity provides Document Actions API. I added an action named "Generate Meta Image". The simplest implementation would be
- You click the button.
- It grabs id, title and description.
- It calls the API.
However, I've got a problem which is a single word alone on the second sentence.
So I needed a bit of manual process to fix things. Here's the final look:

I'm not going to bore you with all the details about Document Actions API. Instead, here comes a bunch of snippets that you can copy&paste right now.
sanity.json
{..."parts": [...{"implements": "part:@sanity/base/document-actions/resolver","path": "./actions/resolveDocumentActions.js"}]}
actions/resolveDocumentActions.js
// eslint-disable-next-line import/no-unresolvedimport defaultResolve from 'part:@sanity/base/document-actions'import { GenerateMetaImage } from './GenerateMetaImage'export default function resolveDocumentActions(props) {return [...defaultResolve(props), GenerateMetaImage]}
actions/GenerateMetaImage/index.js
import React, { useState } from 'react'// eslint-disable-next-line import/no-extraneous-dependenciesimport MdPhoto from 'react-icons/lib/md/photo'// eslint-disable-next-line import/no-unresolvedimport styles from './index.css'function toPlainText(blocks) {if (!blocks) {return ''}return blocks.map(block => {if (block._type !== 'block' || !block.children) {return ''}return block.children.map(child => child.text).join('')}).join('\n\n')}function Form({docId,title: initialTitle,description: initialDescription,onComplete,}) {const [title, setTitle] = useState(initialTitle)const [description, setDescription] = useState(initialDescription)const [generating, setGenerating] = useState(false)return (<div className={styles.container}><p className={styles.label}>Title</p><textareaclassName={styles.input}type="text"value={title}onChange={event => {setTitle(event.target.value)}}></textarea><p className={styles.label}>Description</p><textareaclassName={styles.input}type="text"value={description}onChange={event => {setDescription(event.target.value)}}></textarea><buttonclassName={styles.button}type="button"onClick={() => {setGenerating(true)fetch('https://<INSERT-YOUR-DOMAIN-HERE>/api/create-image', {method: 'POST',headers: {'content-type': 'application/json',},body: JSON.stringify({id: docId,title,description,}),}).then(onComplete)}}disabled={generating}>{generating ? 'Generating...' : 'Generate Image'}</button></div>)}export function GenerateMetaImage({ draft, published, onComplete }) {const [dialogOpen, setDialogOpen] = useState(false)const [docId, setDocId] = useState('')const [title, setTitle] = useState('')const [description, setDescription] = useState('')return {label: 'Generate Main Image',icon: MdPhoto,onHandle: () => {setTitle((draft || published).title)const { excerpt } = draft || publishedsetDescription(toPlainText(excerpt))setDocId((draft || published)._id)setDialogOpen(true)},dialog: dialogOpen && {type: 'modal',onClose: onComplete,content: (<FormdocId={docId}title={title}description={description}onComplete={onComplete}/>),},}}
actions/GenerateMetaImage/index.css
.container {display: flex;flex-direction: column;}.label {font-size: 0.8125rem;line-height: 1.23077;font-weight: 600;color: rgb(33, 43, 57);margin-bottom: 0.5rem;}.input {display: block;font-size: 1rem;font-weight: 400;color: rgb(33, 43, 57);border: 1px solid rgb(174, 184, 200);border-radius: 2px;line-height: 1.25;padding: calc(0.75rem - 3px) calc(0.75rem - 1px) calc(0.75rem - 2px);}.button {margin-top: 2rem;width: 10rem;align-self: flex-start;padding: calc(0.75em - 1px);border: 1px solid rgba(93, 113, 145, 0.35);border-radius: 4px;color: rgb(93, 113, 145);font-size: 1rem;font-weight: 400;background-color: #fff;}.button:hover:not(:disabled) {color: rgb(255, 255, 255);background-color: rgb(84, 102, 131);}.button:disabled {cursor: not-allowed;}
I hope this helps! I'd like to see how you've done with your social images no matter if it's done with this guide or not. Feel free to share yours with me. I'm curious 🙂