
Grab your gear: The official Sanity swag store
Read Grab your gear: The official Sanity swag storeThe issue you're facing is a common security pattern when working with Sanity and Next.js. You're right that you shouldn't expose your write token directly in client-side code. Here's how to solve this properly:
When you add the token directly in client code, it works but exposes your write token to anyone who can view your JavaScript bundle - a major security risk. When you use environment variables prefixed with NEXT_PUBLIC_, they get embedded in the client bundle and are publicly accessible.
You need to keep your write token on the server side. With Next.js App Router, you have two good options:
Create a server action that handles the mutation server-side:
app/actions/createReview.ts:
'use server'
import {createClient} from '@sanity/client'
const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: '2024-01-01',
token: process.env.SANITY_WRITE_TOKEN, // NOT prefixed with NEXT_PUBLIC_
useCdn: false,
})
export async function createReview(reviewData: {
name: string
rating: number
comment: string
// ... other fields
}) {
try {
const result = await client.create({
_type: 'review',
...reviewData,
})
return {success: true, data: result}
} catch (error) {
console.error('Failed to create review:', error)
return {success: false, error: 'Failed to create review'}
}
}Your Review component:
'use client'
import {createReview} from '@/app/actions/createReview'
export function ReviewForm() {
async function handleSubmit(formData: FormData) {
const result = await createReview({
name: formData.get('name') as string,
rating: Number(formData.get('rating')),
comment: formData.get('comment') as string,
})
if (result.success) {
// Handle success
}
}
return (
<form action={handleSubmit}>
{/* Your form fields */}
</form>
)
}app/api/reviews/route.ts:
import {createClient} from '@sanity/client'
import {NextResponse} from 'next/server'
const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: '2024-01-01',
token: process.env.SANITY_WRITE_TOKEN, // Server-side only
useCdn: false,
})
export async function POST(request: Request) {
try {
const body = await request.json()
// Add validation here
const result = await client.create({
_type: 'review',
...body,
})
return NextResponse.json({success: true, data: result})
} catch (error) {
return NextResponse.json(
{success: false, error: 'Failed to create review'},
{status: 500}
)
}
}Your client component:
'use client'
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const response = await fetch('/api/reviews', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(reviewData),
})
const result = await response.json()
}In your .env.local file:
NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-id
NEXT_PUBLIC_SANITY_DATASET=production
SANITY_WRITE_TOKEN=your-write-token # NO NEXT_PUBLIC_ prefix!Make sure your token has the right permissions:
npx sanity manage.env.local as SANITY_WRITE_TOKENThe key principle from the API tokens documentation is that write tokens should never be exposed in frontend code. Always use server-side implementations like Server Actions or API routes where the token remains hidden from client code.
Server Actions are generally preferred in App Router as they're simpler to use and automatically handle things like CSRF protection, but both approaches are secure and valid.
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