Skip to content
51studio
Tutorials

Sanity CMS for marketing sites: a working setup

By Sam Hollis12 min read

Sanity is the default CMS we reach for on Next.js marketing sites. It's not the only good answer. Payload is excellent if you want database-backed content with auth out of the box. Contentful works fine if your team is non-technical and you don't mind a higher monthly bill. Strapi is reasonable if self-hosting is a hard requirement.

But for most marketing sites with a development team, Sanity hits a balance we keep finding hard to beat: real content modelling, an editor experience editors actually like, an embedded Studio that lives next to your codebase, and pricing that doesn't punish you for adding seats.

This is the setup we ship. The decisions, the trade-offs, the things we wish we'd known earlier.

Why Sanity over the alternatives

The deciding factors, in order of how often they actually matter:

Schema-as-code. Sanity content models live in your repository as TypeScript. They're versioned, reviewed, and deployed with the rest of your code. Compare this to Contentful, where schema changes happen in a UI and get out of sync with what the code expects.

Portable Text is genuinely better than rich text fields. Most CMSes give you a WYSIWYG that outputs HTML. Sanity gives you Portable Text, a structured JSON format. The difference shows up when you need to render the same content differently in different contexts (a blog post body, a search result snippet, an OG image). Structured beats string-of-HTML every time.

The embedded Studio. Sanity Studio is a React app. You can run it as a standalone app or embed it in your Next.js site at /studio. Embedded means editors edit content alongside the site, with live preview, without a separate deploy.

GROQ. Sanity's query language. Takes an hour to learn. Beats GraphQL for the kinds of queries marketing sites actually run (joins, conditionals, fallbacks). The coalesce() function alone justifies the learning curve.

Pricing. Generous free tier. Paid tiers are usage-based, not per-editor. You can have twenty editors on the free tier if your content volume is small. Compare to Contentful's "$489/month for five editors" baseline.

The cases where Sanity isn't the right answer: when your team is fully non-technical and Sanity Studio's slightly engineer-feeling UX trips them up; when you need a built-in commerce solution and don't want to integrate Stripe yourself; when you specifically need self-hosting for compliance reasons.

The schema decisions that matter

The way you model content in Sanity sets the ceiling for what's pleasant six months from now. A few decisions you'll make in the first hour:

Bilingual content: dual fields or document-level locales

Sanity has two patterns for multilingual content. We use the dual-field pattern for marketing sites and recommend it.

Dual fields:

ts
defineField({ name: "titleEn", title: "Title (EN)", type: "string" }),
defineField({ name: "titleUk", title: "Title (UK)", type: "string" }),
defineField({ name: "bodyEn", title: "Body (EN)", type: "array", of: [{ type: "block" }] }),
defineField({ name: "bodyUk", title: "Body (UK)", type: "array", of: [{ type: "block" }] }),

One document holds all locales. Editors see both fields side by side in Studio. Queries use coalesce() to fall back to the default locale if a translation is missing:

groq
*[_type == "service" && slug.current == $slug][0]{
  "title": coalesce(titleEn, titleUk),
  "body": coalesce(bodyEn, bodyUk),
  ...
}

Document-level locales:

One document per locale, linked by a translation reference. Better if you have many locales (5+), worse for two. With two locales, the dual-field pattern is simpler to reason about, easier to keep in sync, and produces faster queries.

For marketing sites in two or three locales: dual fields. For products with 10+ locales: document-level.

Portable Text vs. markdown

Sanity's Portable Text is structured JSON. Each paragraph, heading, list item is a block with type info, marks, and children. The render is up to you.

We use Portable Text. The benefits:

  • Custom block types. We have a code block with syntax highlighting (via Shiki, server-rendered). We have an image block with hotspot support and alt text. Native to the schema, not embedded HTML.
  • Predictable rendering. We can change the renderer once and update every blog post.
  • Cleaner migrations. When the project changes typography or spacing, the content doesn't need re-editing.

The downside: editors learning Portable Text takes thirty minutes longer than markdown. We've never had a team push back after the first session.

Singletons vs. collections

Some content is one-of-a-kind: site settings, the homepage hero, footer copy. These are singletons. In Sanity, you enforce singleton-ness in the desk structure, not the schema:

ts
S.listItem()
  .title("Site Settings")
  .id("siteSettings")
  .child(S.editor().id("siteSettings").schemaType("siteSettings").documentId("siteSettings")),

The documentId is fixed. Editors can't accidentally create a second one.

Collections are many-of-the-same: blog posts, services, case studies. Standard list view.

The mistake we see: putting site-wide settings inside a collection ("Settings" collection with one document). Works, but Studio shows it as a list with one item, which is confusing. Use singletons properly from day one.

Slugs per locale

If your URLs are localised (/en/services/landing-page and /uk/services/landing-page), the slug needs to differ per locale. Sanity supports this:

ts
defineField({ name: "slugEn", type: "slug", options: { source: "titleEn" } }),
defineField({ name: "slugUk", type: "slug", options: { source: "titleUk" } }),

Don't share one slug between locales. If you transliterate slugs (the EN slug is landing-page and the UK slug is lendinh), both readers get a URL in a familiar form. Better SEO, better social sharing.

Embedded Studio in Next.js

Sanity Studio can live at /studio in your Next.js app. The setup is:

  1. Install next-sanity and sanity.
  2. Create sanity.config.ts at the repo root with schema types, plugins, and the project ID.
  3. Add a route at app/studio/[[...tool]]/page.tsx that renders NextStudio.
  4. Lock the route in production (auth or robots.txt + noindex; preferably auth).

The advantages over a standalone Studio deploy:

  • One deploy, one repo. Editors and devs are in the same codebase.
  • Live preview works without cross-domain auth headaches.
  • Schema changes ship with code changes. No "Studio is out of sync with the site" bugs.

The disadvantage: the marketing site bundle gets slightly larger if you don't code-split the Studio route carefully. The Studio is heavy. Make sure the Studio is only loaded when someone hits /studio, not on every page load.

Live preview and Draft Mode

Editors want to see changes before publishing. Next.js Draft Mode plus Sanity's preview pane handles this:

  1. Editor edits a document in Studio.
  2. Draft Mode is on (via an API route that sets a cookie).
  3. The Next.js pages, when Draft Mode is on, query Sanity with the perspective set to drafts.
  4. Editor sees the draft live, in the actual rendered page, not in a Studio preview pane.
  5. Publish in Studio commits the change; tag-based revalidation refreshes the production page within seconds.

The two parts to wire up:

ts
// API route that enables Draft Mode
export async function GET(request: Request) {
  const { isValid, redirectTo } = await validateToken(request);
  if (!isValid) return new Response("Unauthorised", { status: 401 });
  (await draftMode()).enable();
  redirect(redirectTo || "/");
}
ts
// Page query
const { isEnabled: isDraftMode } = await draftMode();
const data = await client.fetch(query, params, {
  perspective: isDraftMode ? "drafts" : "published",
});

Token-based auth on the Draft Mode enable route is non-negotiable. Without it, anyone can flip your site into draft mode.

Performance: tag-based revalidation

The default Sanity setup uses Sanity's CDN cache. It works but has trade-offs (cache invalidation lag, less control). For marketing sites where editors want changes to go live within seconds, we prefer Next.js tag-based revalidation.

Each GROQ query gets a tag:

ts
const data = await client.fetch(query, params, {
  next: { tags: ["blog"] },
});

On the Sanity side, a webhook fires on document changes. The webhook hits a Next.js route that calls revalidateTag():

ts
export async function POST(request: Request) {
  const { _type } = await request.json();
  // Map document types to tags
  const tagMap = { blogPost: "blog", service: "services", siteSettings: "settings" };
  revalidateTag(tagMap[_type]);
  return Response.json({ revalidated: true });
}

This pattern gives instant updates (under 2 seconds from publish to live) without rebuilding the site. The pages stay cached otherwise, which is the right default.

The webhook signature should be verified. Sanity sends an HMAC header; check it before processing the webhook.

What Sanity doesn't do well

Honesty about the rough edges:

Image asset management. Sanity's image upload is fine but the asset library UX is utilitarian. If your editors are uploading 50+ images a week and want a polished media library, Sanity will feel underweight. Cloudinary integration is the workaround.

Bulk operations. Updating 100 documents at once requires writing a Node script. Studio doesn't expose bulk operations in the UI. For most teams this is fine. For content-heavy sites with frequent bulk edits, it's a friction point.

Pricing for high content volumes. The free tier handles small marketing sites comfortably. Once you cross the document or API request limits, the paid tier kicks in. For a 50-page marketing site with moderate traffic, you're under the free tier. For a 5,000-document content library with millions of API hits, the bill scales.

Real-time collaboration on the same document. Sanity has real-time updates but two editors typing in the same field at the same time isn't a great experience. Google Docs it isn't. For most marketing teams this never matters.

The setup we ship

End to end, the Sanity setup we ship on a Next.js marketing site:

  • Schema as code with dual-field bilingual pattern
  • Embedded Studio at /studio with auth
  • Tag-based revalidation via webhook (sub-2-second publish-to-live)
  • Draft Mode with token auth for previews
  • Custom Portable Text renderers (code with Shiki, images with hotspot, lists, headings)
  • Singletons for site settings via desk structure
  • GROQ queries colocated with the components that use them, with coalesce() for locale fallback

If you want a marketing site with this setup as the foundation, see how we work on websites. Custom from the schema up, no Webflow template with your logo dropped in.

Related articles