
Grab your gear: The official Sanity swag store
Read Grab your gear: The official Sanity swag storeYou're absolutely right to be thinking about security! Exposing write tokens in client-side code is a big no-no, and the pattern you're describing (using a serverless function as a proxy) is exactly the recommended approach for user-generated content with Sanity.
Here's the typical flow:
approved: false field (or similar)According to Sanity's documentation on API tokens, write tokens should never be exposed in frontend code. Instead:
Here's a basic pattern for a serverless function (this example uses Next.js API routes, but the concept applies to Netlify Functions, Vercel Functions, AWS Lambda, etc.):
// pages/api/submit-post.js (or equivalent for your platform)
import { createClient } from '@sanity/client'
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: process.env.SANITY_DATASET,
token: process.env.SANITY_WRITE_TOKEN, // Robot token with Editor permissions
apiVersion: '2024-01-01',
useCdn: false
})
export default async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).end()
const { text, imageFile } = req.body // You'll need to handle multipart/form-data
// 1. Validate input
if (!text || !imageFile) {
return res.status(400).json({ error: 'Missing required fields' })
}
try {
// 2. Upload asset to Sanity
const asset = await client.assets.upload('image', imageFile, {
filename: imageFile.name
})
// 3. Create document with approval field
const doc = await client.create({
_type: 'userPost',
text: text,
image: {
_type: 'image',
asset: {
_type: 'reference',
_ref: asset._id
}
},
approved: false, // You'll toggle this in Studio
submittedAt: new Date().toISOString()
})
return res.status(200).json({ success: true, id: doc._id })
} catch (error) {
return res.status(500).json({ error: 'Upload failed' })
}
}// schemas/userPost.js
export default {
name: 'userPost',
type: 'document',
title: 'User Submissions',
fields: [
{
name: 'text',
type: 'text',
validation: Rule => Rule.required().max(500)
},
{
name: 'image',
type: 'image'
},
{
name: 'approved',
type: 'boolean',
initialValue: false
},
{
name: 'submittedAt',
type: 'datetime'
}
]
}While the direct upload approach above works well for most cases, if you're dealing with very large files, you might consider a presigned URL pattern (similar to S3 presigned URLs). However, for typical user-generated content (images under a few MB), the direct approach is simpler and sufficient.
The key API you'll use is client.assets.upload(), which handles the actual file upload to Sanity's CDN. The asset gets stored, you get back an asset document with an _id, and you reference that in your content document.
This pattern is well-established in the Sanity community - you're definitely on the right track! The serverless function acts as your secure gateway, keeping credentials safe while enabling user contributions.
Sanity is the developer-first content operating system that gives you complete control. Schema-as-code, GROQ queries, and real-time APIs mean no more workarounds or waiting for deployments. Free to start, scale as you grow.
Content operations
Content backend


The only platform powering content operations
By Industry


Tecovas strengthens their customer connections
Build and Share

Grab your gear: The official Sanity swag store
Read Grab your gear: The official Sanity swag store