

article
Give your merchandising team the keys to the category page
Run campaign drops without the launch-day scramble.
Jarod Reyes
Head of Developer Experience & Community at Sanity
Noah Gentile
Principal Solution Architect at Sanity
Published:


One document to update multiple surfaces. Your content ops lead authors a single campaign document that includes four surfaces: Homepage hero, category badges, the promo landing page, and the email reference. Launch day becomes one publish action, not four.
Automatically teardown campaigns down to the second. When the campaign end date passes, every surface reverts on its own. Don’t worry about the homepage being stuck because a developer was on vacation in Cabo. When the query stops matching, the surface falls back to its default. This is ideal for last-minute product drops or flash sales.
Conflicts get caught before launch. The system blocks publishing a campaign that overlaps with an already-occupied slot. The visibility view shows what is live and what is upcoming across every surface: your marketing ops lead can see, in one view, that the homepage hero is taken through April 18 and the category badge is free.
Where this lands on your P&L. Spend less developer hours on badges and banners. Reduce the risk of an expired promotion staying live and confusing customers. The cost is a one-time frontend implementation against the “slot” model. It is meaningful work but certainly not weeks of work. Once it lands, marketing operates the frontend like a governed system.
The rest of this is for your engineers.
The conventional approach is patch-time (A patch is an edit to a Sanity document. So patch-time campaign management means the campaign happens by editing documents). Launch a campaign by editing every surface document. End a campaign by editing them all back. This breaks for a reason that gets worse the more you scale. You have to snapshot prior state, schedule a revert release, and pray the source documents do not change between staging and revert.
The alternative is query-time. Surface documents are never patched by a campaign. Instead, every promotional slot resolves against an active campaign via a GROQ query at render time. If a campaign matches the slot type and the current time falls inside its date window, the surface renders the campaign slot. If not, the surface falls back to its default content. It’s a fancy if-else statement that is time-based and baked into the GROQ queries.
The campaign goes live the moment the launch date passes. The campaign comes down the moment the end date passes. Both are properties of the query. This is the pattern Sanity's own marketing site has run in production since January 2026.
One caveat: a published campaign is readable in your dataset before its window opens, so anyone hitting the API can see what's coming. For embargoed drops, stage the campaign in a Content Release and schedule it for launch. The date window still handles teardown.
There are two big ideas that make up our campaign document is the orchestration hub. A surfaces[] array on the campaign holds typed slot objects, one per promotional surface the campaign occupies.
// studio/schemas/campaign.ts
defineType({
name: 'campaign',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
defineField({
name: 'campaignType',
type: 'string',
options: { list: ['productDrop', 'seasonal', 'saleEvent', 'collab'] }
}),
defineField({ name: 'launchDate', type: 'datetime', validation: r => r.required() }),
defineField({ name: 'endDate', type: 'datetime', validation: r => r.required() }),
defineField({
name: 'surfaces',
type: 'array',
of: [
{ type: 'homepageHeroSlot' },
{ type: 'categoryBadgeSlot' },
{ type: 'categoryEditorialZoneSlot' },
{ type: 'promoLandingPageSlot' },
{ type: 'emailSlot' }
]
})
]
})Each slot type carries its own decoration fields. A homepageHeroSlot has an image, headline, subheadline, and CTA. A categoryBadgeSlot references the category and carries badge text and color. A promoLandingPageSlot carries the slug, hero, and body content for a landing page that is created per campaign.
The existing homepage and category page documents are never modified. They carry no reference to the campaign. The campaign references them through slot types. That asymmetry is the whole point. You can launch and end a campaign without touching the base documents at all.
For vertical variation, the schema is exposed as a factory function. A product-drop brand wants different slots than a grocery brand that only touches the homepage and global announcement bar. The factory takes a slot configuration:
export const campaignType = generateCampaignType({
slots: ['homepageHero', 'categoryBadge', 'promoLandingPage', 'email']
})Same orchestration hub, different surface combinations per vertical. No forking the reference implementation.
Every promotional surface embeds a GROQ fragment in its existing page fetch. The fragment finds the active campaign, plucks the matching slot's fields for this surface, and returns null if no campaign matches.
"campaignHeroOverride": *[
_type == "campaign"
&& "homepageHero" in surfaces[].slotType
&& dateTime(launchDate) <= dateTime(now())
&& dateTime(endDate) >= dateTime(now())
][0].surfaces[slotType == "homepageHero"][0] {
image,
headline,
subheadline,
cta
}Frontend template logic stays simple. If campaignHeroOverride is present, render it. If not, render the default hero. No conditionals about campaign state across the rest of the application.
The same shape works for every slot type. One fragment per slot. Embed it in the page fetch that already exists for that surface. No separate "active campaign" service call, no second round trip, no client-side reconciliation.
If two campaigns both try to fill the homepage hero on overlapping dates, you do not want to find out at 8:00 AM on launch day. Schema-level validation catches it at author time.
defineField({
name: 'surfaces',
type: 'array',
// ...
validation: r => r.custom(async (surfaces, ctx) => {
const overlap = await ctx.getClient({ apiVersion: '2026-04-08' }).fetch(
`count(*[
_type == "campaign"
&& !(_id in [$id, $publishedId])
&& !(_id in path("drafts.**"))
&& !(_id in path("versions.**"))
&& dateTime(launchDate) <= dateTime($endDate)
&& dateTime(endDate) >= dateTime($launchDate)
&& count(surfaces[slotType in $types]) > 0
])`,
{
id: ctx.document?._id,
publishedId: ctx.document._id.replace('drafts.', ''),
launchDate: ctx.document?.launchDate,
endDate: ctx.document?.endDate,
types: surfaces?.map(s => s.slotType) ?? []
}
)
return overlap === 0 || 'Another campaign fills one of these slots in this date range.'
})
})The result is the editor cannot publish a conflicting campaign. The error surfaces in Studio while they edit, before the campaign is scheduled.
The campaign document and any per-campaign documents (like the promo landing page) are bundled in a Content Release and scheduled for the launch date. At the scheduled time, the release publishes. Every slot query starts matching at the same moment. Publishing early would put embargoed campaign content (copy, imagery, the landing page) into the queryable published dataset before launch. The release holds it out until the moment the embargo is lifted. The customer sees a coordinated launch across the homepage, category pages, landing page, and email reference in the same render.
Content Releases handles two things here. Atomic publish across the campaign document and its companion documents. Schedule-and-forget launch timing. It does not handle delivery to existing surface documents, because the slot model does not need it to. The release calendar shows when each campaign is publishing. It does not show which surfaces a campaign occupies. That is what the visibility matrix is for.
An App SDK standalone application renders a matrix with campaign rows and slot-type columns. That's the spreadsheet, replaced. Each cell shows whether the slot is live, scheduled, or inactive. A "Live Now" filter highlights the current state. Stakeholders who never open Studio can see what is live across the site.
// apps/campaign-matrix/queries.ts
export const ACTIVE_AND_UPCOMING = `
*[_type == "campaign" && dateTime(endDate) > dateTime(now())]
| order(launchDate asc) {
_id, title, campaignType, launchDate, endDate,
"slots": surfaces[]{ slotType, headline, image }
}The matrix is built with App SDK because the marketing ops lead and the leadership reviewer sometimes do not have Studio seats. A Studio tool pane link gets editors back to the campaign document with one click. Both routes resolve to the same data.
The native release calendar shows a schedule of campaigns. The matrix shows which surfaces will be updated.
When an editor opens the homepage page document, they should know whether a campaign is currently filling the hero slot. Otherwise they edit the default hero, expect it to go live, and get confused when the campaign hero shows up instead.
A small Studio hook on the surface document fields queries published campaigns and renders a warning when an active campaign occupies the slot. The editor sees "Campaign X is filling this slot until April 18" right inside the field they are about to edit.
pnpm create sanity@latest --template sanity-labs/campaign-management-starter --package-manager pnpm
cd your-project
pnpm install
cp .env.example .env
pnpm --filter studio exec sanity dataset import seed/data.tar.gz development --replace
pnpm run devThe starter ships the Wander retail storefront, the campaign schema, the slot types, the conflict validation, the useCampaignOverride Studio hook and the GROQ resolution fragments wired into the homepage and category pages. Studio runs at localhost:3333, frontend at localhost:3000. Seed data includes a sample campaign you can schedule against the live storefront.
The reference implementation lives at sanity-labs/campaign-management-starter.
Once the slot model is in place, audience-targeted variants are a clean extension. The same slot object that carries one decoration can carry an override array keyed by audience tag, resolved at the storefront edge, which is the pattern the commerce PLP starter ships today. Nothing about the campaign content model has to change.
If you have tips or questions on how you built your own campaign management tool inside of studio, please let us know: devrel@sanity.io. If you've built slot resolution differently, tell us what broke.