Build automated retail campaign orchestration on Sanity
Run campaign drops without the launch-day scramble.
- One object, every surface. Model the campaign as structured content, homepage, landing pages, badges, and email, you name it.
- Go live in one flip. Every surface updates from a single publish, so you launch "a minute after the game ends" without four people clicking at once.
- Teardown is automatic. No revert checklist. When the window closes, the surfaces clear themselves.

Jarod Reyes
Head of Developer Experience & Community at Sanity

Noah Gentile
Principal Solution Architect at Sanity
Published:


For the Head of Digital Commerce
If your team coordinates promotions in a spreadsheet, this is what changes when you do it correctly.
One document instead of four tools. Your content ops lead authors a single campaign document. Homepage hero, category badges, the promo landing page, and the email reference all live on one record. Launch day becomes one publish action, not four.
Teardown is automatic. When the campaign end date passes, every surface reverts on its own. There is no checklist, no chasing placements, no homepage stuck three days past the end date because a developer was on vacation in Cabo. When the query stops matching, the surface falls back to its default.
Conflicts get caught before launch. The system blocks scheduling a campaign that overlaps an already-occupied slot. The visibility view shows what is live and what is upcoming across every surface. Your marketing ops lead has much more visibility then if they were running it from a spreadsheet.
The numbers other teams have published. Tecovas measured 40% faster product launch cycles and $120K annual savings after moving to this pattern. Riot Games went from 9,000 to 13,000+ releases per year without adding headcount, and reduced contractor dependency on major launches from "10s to 100s of people" to a normal-sized in-house team. Six Flags now reverts campaigns across 51 park sites with a single action.
Where this lands on your P&L. Less developer time spent on badges and banners. Less risk of an expired promotion staying live and confusing customers. More campaigns shipped per quarter without proportionally more coordination overhead. 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 flips the frontend like a governed system instead of a set of individual document edits.
The rest of this article is for your engineers. Hand it over with a thumbs up.
The structural shift
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 decoration. If not, the surface falls back to its default content. Itβs essentially 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.
The content model
Two ideas carry the model. The 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.
Query-time slot resolution
This is the recipe that makes the whole thing work. Every promotional surface embeds a GROQ fragment in its existing page fetch. The fragment finds the active campaign, plucks the decoration for this surface's slot type, 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].decoration
}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.
Conflict validation in the schema
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 != $id
&& !(_id in path("drafts.**"))
&& dateTime(launchDate) <= $endDate
&& dateTime(endDate) >= $launchDate
&& count(surfaces[slotType in $types]) > 0
])`,
{
id: ctx.document?._id,
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 save a conflicting campaign. The error surfaces in Studio at edit time, not after the campaign is scheduled.
Content Releases for the launch
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. 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.
The visibility matrix
This is the piece that closes the spreadsheet workflow. An App SDK standalone application renders a matrix with campaign rows and slot-type columns. Each cell shows whether the slot is live, scheduled, or inactive. A "Live Now" filter highlights the current state. Stakeholders without Studio access 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, decoration }
}
`The matrix is App SDK because the marketing ops lead and the leadership reviewer often 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 schedule. The matrix shows surface occupancy. They are complementary.
The override indicator in Studio
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. This reduces confusion about what is active.
Try it
pnpm create sanity@latest --template sanity-labs/campaign-management-starter --package-manager pnpm cd your-project pnpm install cp .env.example .env pnpm run dev
The starter ships the Wander retail storefront, the campaign schema, the slot types, the conflict validation, the useCampaignOverride Studio hook, the GROQ resolution fragments wired into the homepage and category pages, and the publish-event Function scaffold. 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.
Where this goes next
Once the slot model is in place, the path to audience-targeted campaign variants is clean. When the Variant Primitive ships, the same campaign document becomes the context for release-scoped variants. A Black Friday release can carry a VIP variant and a standard variant for the homepage hero, both inside the same campaign. The query-time slot pattern is the right precursor. Nothing about the content model has to change.
That is the secondary reason to build this now. The primary reason is your content ops lead stops authoring campaigns in a spreadsheet at midnight.