How I set up Sanity document-level permissions with role-based access control

Jun 19, 2026 · 6 min read

Sanity's permissions model is more powerful than most teams realise, and more often than not I see projects where every editor has full write access to every document type. That works until it doesn't — typically the moment a client's marketing intern can delete a product page. This post walks through how I set up document-level role-based access control (RBAC) in Sanity, and how that feeds into a Next.js App Router project.

Why document-level permissions matter

Sanity ships with three built-in roles: Administrator, Editor, and Viewer. For small teams that's fine. For anything with multiple brands, regions, or content teams — agencies building multi-tenant setups especially — you need tighter controls:

  • A regional editor should only write documents where region == 'eu'.
  • A contributor role should create drafts but never publish.
  • A read-only reviewer should see drafts in preview but never mutate.

Sanity's Roles API (available on Growth and Enterprise plans) lets you define custom roles with document-level GROQ filters. The Viewer and Editor roles at the org level stay as fallback; your custom roles sit on top.

Defining custom roles in the Sanity management API

Custom roles aren't configured in sanity.config.ts — they live in your project's settings on sanity.io/manage, or you can script them via the Management API. I prefer scripting so the config lives in version control.

Create a small Node script at scripts/upsert-roles.ts:

// scripts/upsert-roles.ts
import fetch from 'node-fetch'
 
const PROJECT_ID = process.env.SANITY_PROJECT_ID!
const TOKEN = process.env.SANITY_MANAGEMENT_TOKEN! // needs 'Administrator' scope
const DATASET = 'production'
 
const roles = [
  {
    name: 'eu-editor',
    title: 'EU Editor',
    description: 'Can create and edit EU-region documents only',
    grants: [
      {
        filter: `_type in ["post", "product"] && region == "eu"`,
        permissions: ['read', 'create', 'update'],
      },
      {
        // Allow reading all documents for cross-linking
        filter: `_type in ["post", "product"]`,
        permissions: ['read'],
      },
    ],
  },
  {
    name: 'contributor',
    title: 'Contributor',
    description: 'Can create and edit drafts, cannot publish',
    grants: [
      {
        filter: `true`, // all document types
        permissions: ['read', 'create', 'update'],
        // note: no 'publish' — Sanity treats publish as a separate capability
      },
    ],
  },
]
 
async function upsertRole(role: (typeof roles)[0]) {
  const url = `https://api.sanity.io/v2021-10-04/projects/${PROJECT_ID}/roles`
  const res = await fetch(url, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${TOKEN}`,
       'Content-Type': 'application/json',
    },
    body: JSON.stringify(role),
  })
  const json = await res.json()
  console.log(role.name, res.status, json)
}
 
for (const role of roles) {
  await upsertRole(role)
}

Run it once:

SANITY_PROJECT_ID=xyz SANITY_MANAGEMENT_TOKEN=sk... npx tsx scripts/upsert-roles.ts

The GROQ filter in grants is evaluated server-side by Sanity's API on every query and mutation. It is not a Studio-only UI restriction — the API itself enforces it. That's the key thing to understand: a rogue client making direct API calls still hits the same filter.

Restricting the Studio UI to match

The API enforcement is the source of truth, but it's good practice to reflect the restrictions in Studio so editors don't hit confusing 403s. Use the currentUser hook to conditionally hide or disable UI.

In sanity.config.ts you can scope a structure to show only the relevant document types based on the user's roles:

// sanity.config.ts (partial)
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { getCurrentUserRoles } from './studio/lib/getCurrentUserRoles'
 
export default defineConfig({
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
  plugins: [
    structureTool({
      structure: (S, context) => {
        const { currentUser } = context
        const roles = currentUser?.roles?.map((r) => r.name) ?? []
        const isEuEditor = roles.includes('eu-editor')
        const isAdmin = roles.includes('administrator')
 
        const postList = S.listItem()
          .title('Posts')
          .child(
            S.documentList()
              .title('Posts')
              .filter(
                isAdmin
                  ? '_type == "post"'
                  : '_type == "post" && region == "eu"',
              ),
          )
 
        return S.list()
          .title('Content')
          .items([
            postList,
            // other items
          ])
      },
    }),
  ],
})

The currentUser object is available synchronously in the structure callback from Sanity v3. The GROQ filter in .filter() is sent to the API, so it stacks on top of whatever role-level filter already applies — meaning an EU editor will only ever see EU documents regardless of what the structure filter says.

Connecting roles to Next.js

On the Next.js side, document-level permissions are largely invisible because you're querying with a read-only token that has no user context. The RBAC I've described is for Studio users — authenticated editors.

Where this matters in Next.js is in Draft Mode and preview routes. If you're using a preview handler that fetches drafts on behalf of an authenticated user (rather than a bot token), you'd pass the user's Sanity session token and the API would apply their role filters automatically.

More commonly, you'll want to validate in your route handler that the requesting editor is authorised before handing back a preview URL:

// app/api/preview/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { draftMode } from 'next/headers'
import { client } from '@/sanity/lib/client'
 
export async function GET(req: NextRequest) {
  const { searchParams } = req.nextUrl
  const secret = searchParams.get('secret')
  const slug = searchParams.get('slug')
  const docType = searchParams.get('type') ?? 'post'
 
  if (secret !== process.env.SANITY_PREVIEW_SECRET) {
    return NextResponse.json({ message: 'Invalid token' }, { status: 401 })
  }
 
  // Verify the document exists at all before enabling draft mode
  const exists = await client.fetch(
    `count(*[_type == $type && slug.current == $slug]) > 0`,
    { type: docType, slug },
  )
 
  if (!exists) {
    return NextResponse.json({ message: 'Document not found' }, { status: 404 })
  }
 
  const dm = await draftMode()
  dm.enable()
 
  return NextResponse.redirect(new URL(`/${docType}/${slug}`, req.url))
}

This is thin — it just verifies the secret and document existence. If you need user-level gating (only let this editor preview their region's documents), add a Sanity user lookup against the management API before enabling draft mode.

What you actually get

After wiring this up: EU editors see and can only mutate EU documents in both the API and Studio, contributors can draft but not publish (Sanity's publish action checks for the publish permission in the role grant and surfaces an error if absent), and previewing drafts is still a single-secret gate at the Next.js layer. The system is enforced at the API, mirrored in the Studio UI, and leaves the public Next.js frontend untouched.

The one thing to watch: Sanity's GROQ filter in a role grant runs against the full document, including the drafts. prefix documents. Make sure your region field is set before the first save, or drafts won't appear even to the correct role until a region is chosen.

Related posts

All posts →