How to safely upload user-submitted images to Sanity from a static site?
You'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.
The Standard Pattern
Here's the typical flow:
- User submits form data + image from your static site
- Your serverless function receives the request
- Function validates/moderates the content
- Function uploads the asset to Sanity using a write-enabled API token (stored securely as an environment variable)
- Function creates a document with an
approved: falsefield (or similar) - You review and approve posts in Sanity Studio
- Approved content appears on your site
Security Best Practices
According to Sanity's documentation on API tokens, write tokens should never be exposed in frontend code. Instead:
- Use robot tokens (for production) with Editor permissions
- Store them as environment variables in your serverless function
- Keep all write operations server-side
Implementation Example
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' })
}
}Schema Example
// 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'
}
]
}Platform-Specific Notes
- Vercel/Next.js: API routes work great for this
- Netlify: Use Netlify Functions (similar pattern)
- AWS Lambda: More setup but full control
- Cloudflare Workers: Good option if you're on Cloudflare
- Sanity Functions: If you're on a paid Sanity plan, you could use Sanity Functions for this - serverless compute built directly into Sanity
Alternative: Presigned URLs
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.
Asset Upload Details
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.
Show original thread1 reply
Sanity – Build the way you think, not the way your CMS thinks
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.