
Using Sanity for Multi-Site Content Management
To use Sanity for multi-site content management, choose how sites share data, then model it. For most projects the best approach is one dataset with a site document type: every page, post and settings document references the site it belongs to, shared content references several sites or none, and each front end queries with a $site parameter. Studio workspaces or a custom structure then give each site's editors their own filtered view. Use a separate dataset per site only when content must be strictly isolated, and a separate project only when sites have nothing in common.
Brands, regional sites, microsites and white-label products all raise the same question: one CMS or many? Sanity handles multi-site setups well because content is just structured data you query, not pages tied to one website. This guide compares the three architectures, then builds the shared-dataset approach step by step: the site model, per-site slugs and references, Studio workspaces, initial values, and front-end queries, including a multi-tenant Next.js app.
Three Ways to Structure Multiple Sites
| Approach | Shared content | Isolation | Cost and limits | Best for |
|---|---|---|---|---|
One dataset, site field | Easy, with normal references | By convention and filters | No extra datasets needed | Related sites that share authors, products or components |
| One dataset per site | Only with cross-dataset references (Enterprise) | Strong, with per-dataset permissions | Uses dataset allowance | Brands or regions with separate teams and little overlap |
| One project per site | None, except by copying | Complete | Separate seats and quotas per project | Unrelated sites, or sites owned by different clients |
The plan you're on matters here. Free and Growth plans include two datasets, and the Sanity pricing guide notes that extra datasets are a paid add-on. Since you'll usually want one of those two for staging, a dataset per site is rarely practical below Enterprise. A shared dataset fits within any plan.
Datasets are covered in detail in understanding Sanity datasets and environments. The rest of this guide focuses on the shared-dataset approach, with notes on the alternatives where they differ.
Step 1: Model the Site Itself
Make each site a document. That gives you a place for its name, domain, locale and default SEO settings, and lets other documents reference it:
// schemaTypes/site.ts
import { defineField, defineType } from 'sanity';
import { EarthGlobeIcon } from '@sanity/icons';
export const site = defineType({
name: 'site',
title: 'Site',
type: 'document',
icon: EarthGlobeIcon,
fields: [
defineField({ name: 'title', type: 'string', validation: (rule) => rule.required() }),
defineField({
name: 'key',
title: 'Site key',
type: 'slug',
description: 'Short identifier used in code, such as "brand-a"',
validation: (rule) => rule.required(),
}),
defineField({ name: 'domain', type: 'string', description: 'For example brand-a.com' }),
defineField({ name: 'defaultLocale', type: 'string', initialValue: 'en' }),
],
});
Create the site documents with fixed IDs, such as site-brand-a and site-brand-b, so code can refer to them reliably. You can create them in Studio and then copy the ID, or create them with a script using createIfNotExists.
Step 2: Attach Content to Sites
There are two common patterns, and you can mix them in one project:
- Single-site documents have a required
sitereference. Pages and navigation are usually like this. - Shared documents have a
sitesarray. Leave it empty to mean "all sites", or list the sites where the item appears. Authors, products, legal text and reusable components often work this way.
A reusable field keeps the definition consistent across types:
// schemaTypes/fields/site.ts
import { defineArrayMember, defineField } from 'sanity';
export const siteField = defineField({
name: 'site',
type: 'reference',
to: [{ type: 'site' }],
validation: (rule) => rule.required(),
options: { disableNew: true },
});
export const sitesField = defineField({
name: 'sites',
title: 'Show on sites',
description: 'Leave empty to show on every site',
type: 'array',
of: [defineArrayMember({ type: 'reference', to: [{ type: 'site' }] })],
options: { layout: 'tags' },
});
Add siteField to page and post, and sitesField to shared types. How to use references in Sanity schemas explains the reference options used here.
Step 3: Keep Slugs Unique Per Site
By default, Sanity's slug field checks uniqueness across the whole dataset. With several sites, /about must be allowed once per site. Provide a custom isUnique function:
// schemaTypes/lib/isUniquePerSite.ts
import type { SlugIsUniqueValidator } from 'sanity';
export const isUniquePerSite: SlugIsUniqueValidator = async (slug, context) => {
const { document, getClient } = context;
if (!document) return true;
const id = document._id.replace(/^drafts\./, '');
const site = (document.site as { _ref?: string } | undefined)?._ref;
return getClient({ apiVersion: '2025-02-19' }).fetch(
`!defined(*[
_type == $type &&
slug.current == $slug &&
site._ref == $site &&
!(_id in [$id, "drafts." + $id])
][0]._id)`,
{ type: document._type, slug, site: site ?? '', id },
);
};
Use it on the slug field with options: { source: 'title', isUnique: isUniquePerSite }. How to create custom slugs in Sanity covers slug options in more depth.
Step 4: Restrict References to the Same Site
An editor working on Brand A's navigation shouldn't be able to link to Brand B's pages by accident. A reference filter limits the choices to documents on the same site, plus shared documents:
defineField({
name: 'links',
type: 'array',
of: [
defineArrayMember({
type: 'reference',
to: [{ type: 'page' }, { type: 'post' }],
options: {
filter: ({ document }) => {
const site = (document.site as { _ref?: string } | undefined)?._ref;
return {
filter: 'site._ref == $site || count(sites) == 0 || $site in sites[]._ref',
params: { site: site ?? '' },
};
},
},
}),
],
}),
This is a Studio convenience, not a security boundary. If sites need strict separation of who can see or edit what, you'll need datasets per site or Enterprise custom roles with content filters, covered in managing user roles and permissions.
Step 5: Per-Site Settings
Each site needs its own settings: logo, navigation, footer, default SEO. Treat them as singletons with a predictable ID per site, such as siteSettings-brand-a:
*[_id == "siteSettings-" + $siteKey][0]{
title,
logo,
"navigation": navigation[]->{ title, "slug": slug.current }
}
The singleton documents guide shows how to pin a document to a fixed ID in the Structure tool and hide the "create new" option. The same technique works with one singleton per site.
Step 6: Give Each Site Its Own Workspace
Workspaces let one Studio show several configurations, each with its own title, icon, tools and structure, selected from a menu in the navbar. Pointing several workspaces at the same dataset gives each site's editors a focused view without duplicating any content:
// sanity.config.ts
import { defineConfig } from 'sanity';
import { structureTool } from 'sanity/structure';
import { visionTool } from '@sanity/vision';
import { schemaTypes } from './schemaTypes';
import { siteStructure } from './structure/siteStructure';
const sites = [
{ key: 'brand-a', id: 'site-brand-a', title: 'Brand A' },
{ key: 'brand-b', id: 'site-brand-b', title: 'Brand B' },
];
export default defineConfig(
sites.map((site) => ({
name: site.key,
title: site.title,
basePath: `/${site.key}`,
projectId: '<project-id>',
dataset: 'production',
plugins: [structureTool({ structure: siteStructure(site.id) }), visionTool()],
schema: {
types: schemaTypes,
templates: (prev) => [
...prev.filter((template) => !['page', 'post'].includes(template.schemaType)),
...['page', 'post'].map((type) => ({
id: `${type}-${site.key}`,
title: `${type === 'page' ? 'Page' : 'Post'} (${site.title})`,
schemaType: type,
value: { site: { _type: 'reference', _ref: site.id } },
})),
],
},
})),
);
The templates function replaces the default templates for pages and posts with site-specific ones, so a new document created in Brand A's workspace already references Brand A. How to set initial values in Sanity documents explains templates further.
The structure then filters lists by site:
// structure/siteStructure.ts
import type { StructureResolver } from 'sanity/structure';
export const siteStructure =
(siteId: string): StructureResolver =>
(S) =>
S.list()
.title('Content')
.items([
...['page', 'post'].map((type) =>
S.listItem()
.title(type === 'page' ? 'Pages' : 'Posts')
.schemaType(type)
.child(
S.documentTypeList(type)
.apiVersion('2025-02-19')
.filter('_type == $type && site._ref == $siteId')
.params({ type, siteId })
.initialValueTemplates([S.initialValueTemplateItem(`${type}-${siteId.replace('site-', '')}`)]),
),
),
S.divider(),
S.documentTypeListItem('author').title('Authors (shared)'),
S.documentTypeListItem('site').title('Sites'),
]);
Workspaces control what editors see, not what they can access. To hide a workspace from some users, the hidden option on a workspace accepts a function of the current user, but real access control still comes from roles. How to customise the Sanity Studio desk structure covers the Structure Builder in more detail.
When Each Site Has Its Own Dataset
If you chose a dataset per site instead, the workspace config is even simpler: give each workspace a different dataset value and drop the site filters. Content in different datasets can't use normal references, though. Sharing it requires cross-dataset references, which are an Enterprise feature, or duplicating shared content into each dataset with a script.
Step 7: Query Content Per Site
On the front end, every query takes the site as a parameter. A page query looks like this:
*[_type == "page" && site->key.current == $site && slug.current == $slug][0]{
title,
body,
seo
}
And shared content uses the "empty means everywhere" rule:
*[_type == "product" && (count(sites) == 0 || $site in sites[]->key.current)]
| order(title asc){ title, "slug": slug.current, price }
Filtering on site._ref == $siteId is faster than dereferencing with site->key.current, so pass the site document's ID if you have it. Always pass the site as a GROQ parameter rather than building it into the query string.
One Deployment per Site
The simplest front-end setup is one deployment per site from the same codebase, each with a SITE_KEY environment variable:
// lib/sanity.ts
import { createClient } from 'next-sanity';
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: '2025-02-19',
useCdn: true,
});
export const siteKey = process.env.SITE_KEY!;
One Multi-Tenant App
If you'd rather run all sites from one Next.js app, map the hostname to a site in proxy.ts (called middleware before Next.js 16) and rewrite to a dynamic [site] segment:
// proxy.ts
import { NextResponse, type NextRequest } from 'next/server';
const hosts: Record<string, string> = {
'brand-a.com': 'brand-a',
'brand-b.com': 'brand-b',
};
export function proxy(request: NextRequest) {
const host = request.headers.get('host')?.replace(/^www\./, '') ?? '';
const site = hosts[host] ?? 'brand-a';
const url = request.nextUrl.clone();
url.pathname = `/${site}${url.pathname}`;
return NextResponse.rewrite(url);
}
export const config = {
matcher: ['/((?!_next|api|favicon.ico).*)'],
};
// app/[site]/[slug]/page.tsx
import { defineQuery } from 'next-sanity';
import { notFound } from 'next/navigation';
import { client } from '@/lib/sanity';
const pageQuery = defineQuery(`
*[_type == "page" && site->key.current == $site && slug.current == $slug][0]{ title, body }
`);
export default async function Page({ params }: { params: Promise<{ site: string; slug: string }> }) {
const { site, slug } = await params;
const page = await client.fetch(pageQuery, { site, slug });
if (!page) notFound();
return <h1>{page.title}</h1>;
}
In Next.js 16, params is a Promise, so it's awaited before use. Keeping the host-to-site map in code is fine for a handful of sites. For many, fetch it from the site documents at build time.
Operational Considerations
A few things become more important once several sites share one project:
- Webhooks and revalidation: include the site in the webhook projection, such as
{"site": site->key.current, "slug": slug.current}, so only the affected site revalidates. Triggering site rebuilds with Sanity webhooks shows the pattern. - API usage: all sites draw from the same project quotas, so a traffic spike on one site affects the budget for all of them.
- Schema changes: every site uses the same schema. Add site-specific fields sparingly, and hide them with conditional fields based on the site where needed.
- Previews: pass the site to your preview URL resolver so the Presentation tool opens the right domain.
FAQ: Multi-Site Content Management With Sanity
Yes. Content in Sanity isn't tied to a single website, so several front ends can query the same project. The usual approach is a shared dataset where documents reference the site they belong to, with each front end filtering by its own site.
Use one dataset when sites share content or teams, because normal references work and you don't use extra datasets. Use separate datasets when content must be isolated, teams don't overlap and you're on a plan with enough datasets. Cross-dataset references are an Enterprise feature.
Workspaces are separate configurations within one Studio, each with its own name, base path, tools, structure and optionally dataset. Editors switch between them from a menu in the navbar. They're ideal for giving each site's editors a focused view.
Provide a custom isUnique function on the slug field that checks for other documents with the same slug, type and site reference, excluding the current document's draft and published IDs. The default check is dataset-wide.
Workspaces and filtered structures only change what editors see. To actually restrict access, use a dataset per site with dataset-scoped roles, or Enterprise custom roles with content filters on a shared dataset.
Give shared document types a sites array of references and treat an empty array as "all sites". Front-end queries then include documents where count(sites) == 0 or where the current site is in the array.
Conclusion
Sanity suits multi-site content management because content is structured data rather than pages owned by one website. For most teams, one dataset with a site document type is the best starting point: references work across sites, shared content is easy, and it fits any plan. Per-site slugs, reference filters, workspaces and initial value templates make the Studio feel like a dedicated CMS for each site.
Choose separate datasets or projects only when you need stronger isolation than filters can provide. Whichever architecture you pick, pass the site as a query parameter everywhere, include it in webhooks, and keep the site list in one place so adding the next site is a configuration change rather than a rebuild.


