<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nayan Kyada</title>
    <description>The latest articles on DEV Community by Nayan Kyada (@nayankyada).</description>
    <link>https://dev.to/nayankyada</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2638501%2Fb0cc4a93-db40-4087-9ff8-b4c2debac8a1.jpg</url>
      <title>DEV Community: Nayan Kyada</title>
      <link>https://dev.to/nayankyada</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nayankyada"/>
    <language>en</language>
    <item>
      <title>Why I Use Next.js + Sanity for Content Sites</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Wed, 20 May 2026 17:02:56 +0000</pubDate>
      <link>https://dev.to/nayankyada/why-i-use-nextjs-sanity-for-content-sites-3dg8</link>
      <guid>https://dev.to/nayankyada/why-i-use-nextjs-sanity-for-content-sites-3dg8</guid>
      <description>&lt;p&gt;If you’re building a marketing site or content platform, you want three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pages that load fast,&lt;/li&gt;
&lt;li&gt;content that’s easy to edit,&lt;/li&gt;
&lt;li&gt;and an SEO setup you can trust.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most projects I ship, &lt;strong&gt;Next.js + Sanity&lt;/strong&gt; is the sweet spot.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Next.js gives you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Great performance defaults&lt;/strong&gt;: route-based code splitting, image optimisation, and server rendering when needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata control&lt;/strong&gt;: canonicals, Open Graph, Twitter cards, and structured data can be treated as first-class code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment simplicity&lt;/strong&gt;: ship to Vercel (or any Node host) and keep it boring.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  When Next.js is the right tool (and when it’s not)
&lt;/h3&gt;

&lt;p&gt;Next.js is ideal when you care about &lt;strong&gt;speed + SEO + developer velocity&lt;/strong&gt; together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Marketing sites&lt;/strong&gt; with landing pages that must load instantly and share well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content sites&lt;/strong&gt; where posts need to be crawlable, linkable, and structured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid apps&lt;/strong&gt; where some pages are static (blog) and others are dynamic (pricing, dashboards, gated content).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When I &lt;em&gt;don’t&lt;/em&gt; reach for Next.js:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the site is purely static and will never need dynamic data, a simpler static generator can be enough.&lt;/li&gt;
&lt;li&gt;If you’re building an internal tool with no SEO needs, you may prioritise different trade-offs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The SEO primitives you get “as code”
&lt;/h3&gt;

&lt;p&gt;The big win is that SEO becomes part of your engineering surface area:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Canonical URLs&lt;/strong&gt;: avoid duplicate indexing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenGraph/Twitter&lt;/strong&gt;: previews that look consistent across platforms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured data&lt;/strong&gt; (JSON-LD): help Google understand the page type and relationships (author, breadcrumbs, collections).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sitemaps + robots&lt;/strong&gt;: generated + validated like any other build artifact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re building a blog, that means every post can ship with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a canonical,&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;BlogPosting&lt;/code&gt; schema,&lt;/li&gt;
&lt;li&gt;and a stable OG image route (like &lt;code&gt;/api/og/blog/[slug]&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Sanity gives you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flexible content modelling&lt;/strong&gt;: you can represent real business concepts instead of forcing everything into a “blog post” shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editorial velocity&lt;/strong&gt;: drafts, previews, and publishing without developer tickets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured SEO fields&lt;/strong&gt;: titles, descriptions, canonicals, and share images can be part of the schema.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Sanity is not “just a CMS” — it’s a content database
&lt;/h3&gt;

&lt;p&gt;Most teams hit limits when their CMS only supports “Page” and “Post”.&lt;br&gt;
Sanity lets you model the real world:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authors&lt;/strong&gt; (with bios, socials, headshots)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Categories&lt;/strong&gt; (and content verticals)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reusable blocks&lt;/strong&gt; (CTAs, testimonials, FAQs)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relationships&lt;/strong&gt; (related posts, featured projects, “learn more” links)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That structure is what makes a site scale without becoming chaos.&lt;/p&gt;

&lt;h3&gt;
  
  
  A blog model that scales (simple but future-proof)
&lt;/h3&gt;

&lt;p&gt;If I’m setting up a blog, I start with a schema that supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;slug&lt;/strong&gt; (stable URL)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;title + description&lt;/strong&gt; (SERP + social)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;publishedAt&lt;/strong&gt; (ordering)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tags/categories&lt;/strong&gt; (internal navigation + topical authority)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;body&lt;/strong&gt; (portable rich text)&lt;/li&gt;
&lt;li&gt;optional &lt;strong&gt;featured image&lt;/strong&gt; (sharing + in-article media)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can keep it lightweight at first, and expand only when you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-offs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Sanity is another system to manage (datasets, roles, previews).&lt;/li&gt;
&lt;li&gt;If you only need a handful of posts, MDX in the repo can be enough.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What it costs (so you can plan properly)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;More moving parts&lt;/strong&gt;: environment variables, datasets, permissions, preview URLs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More decisions up front&lt;/strong&gt;: your schema design affects how editors work every day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preview complexity&lt;/strong&gt;: “draft vs published” needs a clean workflow (it’s worth it, but it’s work).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are deal-breakers — they’re just real.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to decide: MDX vs Sanity (quick framework)
&lt;/h2&gt;

&lt;p&gt;Use &lt;strong&gt;MDX in the repo&lt;/strong&gt; when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you’ll publish infrequently (or you’re the only editor),&lt;/li&gt;
&lt;li&gt;you want “blog as code” and don’t need editorial workflows,&lt;/li&gt;
&lt;li&gt;you care about shipping fast and keeping infra minimal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use &lt;strong&gt;Sanity&lt;/strong&gt; when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;multiple people need to publish,&lt;/li&gt;
&lt;li&gt;content types will grow beyond “blog post”,&lt;/li&gt;
&lt;li&gt;you want drafts, approvals, and previews,&lt;/li&gt;
&lt;li&gt;you want a long-term content pipeline (case studies, landing pages, docs).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A practical implementation plan (what I ship for clients)
&lt;/h2&gt;

&lt;p&gt;Here’s the sequence I follow for a high-performing content site:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Define content types&lt;/strong&gt;: start minimal (post, author, category).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build listing + detail pages&lt;/strong&gt;: &lt;code&gt;/blog&lt;/code&gt; and &lt;code&gt;/blog/[slug]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add technical SEO&lt;/strong&gt;: canonicals, JSON-LD, sitemap, RSS, OG images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add internal linking&lt;/strong&gt;: “related posts” + links from services/projects pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure + iterate&lt;/strong&gt;: Search Console, Core Web Vitals, and content refresh cycles.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Common mistakes I see (and how to avoid them)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Thin posts&lt;/strong&gt;: short posts without a unique angle won’t build authority. Prefer fewer, deeper articles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No internal links&lt;/strong&gt;: link your posts to relevant pages (and between posts) so crawlers understand structure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unstable slugs&lt;/strong&gt;: never change slugs once indexed unless you have redirects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing OG images&lt;/strong&gt;: social previews matter for distribution (and distribution matters for links).&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nextjs</category>
      <category>sanity</category>
      <category>seo</category>
    </item>
    <item>
      <title>Signs your WordPress site needs a headless CMS rebuild</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Wed, 20 May 2026 17:00:52 +0000</pubDate>
      <link>https://dev.to/nayankyada/signs-your-wordpress-site-needs-a-headless-cms-rebuild-5agm</link>
      <guid>https://dev.to/nayankyada/signs-your-wordpress-site-needs-a-headless-cms-rebuild-5agm</guid>
      <description>&lt;p&gt;If your marketing team is filing support tickets just to change a headline, or your Google Ads campaigns are bleeding budget because the landing pages load in four seconds, your WordPress site may have hit a structural ceiling — not a content problem, not a design problem. A structural one. These are the signs your WordPress site needs a headless CMS rebuild, and knowing them early saves you from throwing more money at band-aids.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 'headless' actually means for your business
&lt;/h2&gt;

&lt;p&gt;WordPress bundles everything together: the place editors write content, the code that decides how it looks, and the server that delivers it to visitors. That bundle made setup fast in 2012. In 2026 it creates friction at every layer.&lt;/p&gt;

&lt;p&gt;A headless setup separates the content system from the presentation layer. Your editors still log in to a clean dashboard — usually something like Sanity Studio — and write, publish, and schedule exactly as before. But the front end is a purpose-built website (built on Next.js in my projects) that fetches that content and renders it at speed, with full control over layout, performance, and where the content goes next. The content isn't trapped in one theme's templates. It can feed your website, your mobile app, a digital signage screen in your showroom, or an email campaign — all from the same source.&lt;/p&gt;

&lt;p&gt;That separation is the point. It removes the ceiling.&lt;/p&gt;

&lt;h2&gt;
  
  
  The warning signs worth taking seriously
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Editors are hacking around the theme.&lt;/strong&gt; I worked with a sports organisation whose content team had resorted to embedding raw HTML in text fields to get a two-column layout the theme didn't support. They'd built invisible spacer images to control padding. Every new page took 45 minutes and still looked slightly off. This is a sign that the editorial experience has calcified around a theme that was never designed for what the organisation actually needed to publish.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance scores are killing paid-ad ROI.&lt;/strong&gt; A training academy I rebuilt was running Google Ads to a set of course landing pages. Their average mobile PageSpeed score was 41. Industry data is consistent here: pages loading beyond three seconds lose more than half of mobile visitors before the page even finishes loading. The academy's cost-per-conversion was two to three times what it should have been. The WordPress install had eleven active page-builder plugins, four of which loaded JavaScript on every page regardless of whether that page used them. No amount of caching plugin configuration was going to fix that. The payload was structural.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dev time is eaten by plugin conflicts.&lt;/strong&gt; When a WooCommerce update breaks the slider plugin which breaks the checkout page, and the fix involves downgrading a security patch — that's not bad luck, that's the compounding cost of a plugin ecosystem with no central contract. One housing developer I worked with was spending roughly two days per month on this kind of firefighting. That's 24 days a year of developer time producing zero new value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your content can't go anywhere except the website.&lt;/strong&gt; The housing developer also wanted to launch a buyer portal — a separate web app where prospective buyers could track a property's construction milestones. All the project descriptions, floor plans, and images lived in WordPress. Getting that content into the portal meant either duplicating it manually or building a fragile custom REST API around a database schema that was never designed to be queried that way. In a headless setup, content is structured from day one to be delivered anywhere via a clean API. The buyer portal becomes a straightforward project, not a six-month integration nightmare.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You're adding pages by duplicating and editing old ones.&lt;/strong&gt; This is quieter than the others but just as telling. When there's no real component system — just a grab-bag of shortcodes and widget areas — teams default to cloning whatever page last worked. The result is a site with 200 pages that are all slightly different versions of three templates, none of them documented, and any design change has to be made 200 times.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the rebuild project actually looks like
&lt;/h2&gt;

&lt;p&gt;A typical engagement runs eight to fourteen weeks depending on content volume and integration complexity. The first two weeks are spent on content modelling — mapping what you publish today into structured schemas that will work for years. This is the most important work and the part most agencies skip, which is why rebuilds sometimes recreate the same problems in a new tool.&lt;/p&gt;

&lt;p&gt;Editors get a staging Studio environment by week three or four. The goal is for them to feel confident before the old site is retired, not after. By the midpoint, the new site exists alongside the old one. You can compare performance, share it with stakeholders, and catch gaps without any public risk.&lt;/p&gt;

&lt;p&gt;Content migration from WordPress is usually scripted — posts, images, and structured data move across without manual copy-paste. The edge cases (custom post types built by a plugin that no longer exists, images stored in twelve different places) are where honest scoping matters. I flag these in discovery so there are no surprises at handoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  When it's worth it — and when it isn't
&lt;/h2&gt;

&lt;p&gt;A headless rebuild earns its cost when performance is directly tied to revenue (paid advertising, e-commerce, lead generation), when content needs to reach more than one channel, or when editorial bottlenecks are slowing down a team that publishes frequently.&lt;/p&gt;

&lt;p&gt;It is not the right call for a five-page brochure site with no editorial team and no plans to grow. WordPress with a well-configured theme and no unnecessary plugins is genuinely fine for that use case. The rebuild conversation is for organisations that have outgrown the bundle — where the original setup is now fighting against what the business needs to do.&lt;/p&gt;

&lt;p&gt;If you recognise two or more of the situations above, the question isn't really whether to rebuild. It's whether to do it now or after the next plugin conflict takes down a campaign you can't afford to lose.&lt;/p&gt;

</description>
      <category>headlesscms</category>
      <category>wordpress</category>
      <category>sanitycms</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Sanity CMS vs Contentful for Next.js projects: an honest comparison</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Wed, 20 May 2026 17:00:49 +0000</pubDate>
      <link>https://dev.to/nayankyada/sanity-cms-vs-contentful-for-nextjs-projects-an-honest-comparison-56l</link>
      <guid>https://dev.to/nayankyada/sanity-cms-vs-contentful-for-nextjs-projects-an-honest-comparison-56l</guid>
      <description>&lt;p&gt;When a client asks me to recommend a CMS for their Next.js project, the choice almost always comes down to Sanity CMS vs Contentful. Both are mature headless platforms with solid Next.js support, but they make very different bets on query language, pricing, and developer experience. I've shipped production projects on both, and the gap is more nuanced than the marketing suggests.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you're actually comparing
&lt;/h2&gt;

&lt;p&gt;Contentful is a SaaS-first platform. You get a hosted API, a structured content model editor, and a well-documented REST/GraphQL API. Everything is managed for you — schema migrations, Studio hosting, CDN delivery. That's the value proposition.&lt;/p&gt;

&lt;p&gt;Sanity ships a content lake (the hosted backend) plus an open-source Studio that you own, configure, and deploy yourself. Schemas live in your repo as TypeScript files. You query with GROQ, a purpose-built query language. The Studio runs as a Next.js route or a standalone Vite app depending on how you wire it.&lt;/p&gt;

&lt;p&gt;That distinction — schema-in-repo vs schema-in-dashboard — changes almost every downstream decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  GROQ vs GraphQL: the real query language trade-off
&lt;/h2&gt;

&lt;p&gt;This is where most developers form a strong opinion fast.&lt;/p&gt;

&lt;p&gt;Contentful's GraphQL API is standard and predictable. If your team already knows GraphQL, you're productive on day one. Tooling like GraphQL Code Generator gives you typed responses with minimal config.&lt;/p&gt;

&lt;p&gt;GROQ is Sanity's query language and, after a week with it, I find it meaningfully more expressive for content shapes. You can dereference, filter, slice, and project in a single query without nested fragment boilerplate. Here's a real GROQ query I use for a blog index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Used in: app/(blog)/blog/page.tsx
*[_type == "post" &amp;amp;&amp;amp; defined(publishedAt) &amp;amp;&amp;amp; !(_id in path("drafts.**"))]
  | order(publishedAt desc)[0...12] {
  _id,
  title,
  "slug": slug.current,
  publishedAt,
  "author": author-&amp;gt;{ name, "avatar": image.asset-&amp;gt;url },
  "categories": categories[]-&amp;gt;{ title, "slug": slug.current },
  "lqip": coverImage.asset-&amp;gt;metadata.lqip,
  "dimensions": coverImage.asset-&amp;gt;metadata.dimensions
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The equivalent in Contentful GraphQL requires separate &lt;code&gt;authorCollection&lt;/code&gt; and &lt;code&gt;categoryCollection&lt;/code&gt; queries or deeply nested fragments. It's not unmanageable, but it accumulates friction on complex content models.&lt;/p&gt;

&lt;p&gt;The downside for GROQ: it's proprietary. New engineers need to learn it, and there's no ecosystem of generic tooling. Sanity TypeGen partially bridges this by generating TypeScript types from your queries, but you have to run it as part of your build pipeline.&lt;/p&gt;

&lt;p&gt;Contentful GraphQL wins on ecosystem familiarity. GROQ wins on expressiveness for relational content.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing for small teams in 2026
&lt;/h2&gt;

&lt;p&gt;This has shifted. As of mid-2026, Sanity's free tier gives you 3 users, 2 non-admin users, and a 500k API CDN request per month limit with 10 GB bandwidth. Their Growth plan starts around $15/month per seat.&lt;/p&gt;

&lt;p&gt;Contentful's free tier is more generous on seat count (5 users) but caps content types at 48 and API calls at 1M per month on the Community plan. Their Basic plan starts around $300/month flat, which jumps hard for small teams.&lt;/p&gt;

&lt;p&gt;For a freelance engagement with 2–4 editors and a modest content volume, Sanity is cheaper by a wide margin. For enterprise teams already on Contentful with existing contracts, switching cost outweighs the savings.&lt;/p&gt;

&lt;p&gt;The Sanity free tier is also genuinely usable for client handoffs — I regularly leave clients on the free plan for low-traffic sites.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next.js integration DX
&lt;/h2&gt;

&lt;p&gt;Both platforms have official Next.js packages. The experience differs in meaningful ways.&lt;/p&gt;

&lt;p&gt;Contentful's &lt;code&gt;contentful&lt;/code&gt; npm package is typed but ships its own SDK with a non-trivial bundle size. Fetching content in an RSC looks clean, but you lose granular control over caching because you're going through their SDK rather than native &lt;code&gt;fetch&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Sanity's &lt;code&gt;next-sanity&lt;/code&gt; package wraps the Sanity client and plugs directly into Next.js's &lt;code&gt;fetch&lt;/code&gt; cache with &lt;code&gt;revalidate&lt;/code&gt; tags. This matters for ISR. Here's what a cache-tagged fetch looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/(blog)/blog/[slug]/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sanityFetch&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/sanity/lib/fetch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;postBySlugQuery&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/sanity/lib/queries&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PostBySlugQueryResult&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/sanity/types&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;PostPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sanityFetch&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;PostBySlugQueryResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;postBySlugQuery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`post:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;notFound&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PostLayout&lt;/span&gt; &lt;span class="na"&gt;post&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;tags&lt;/code&gt; array maps directly to on-demand revalidation from a Sanity webhook. Contentful can do tag-based revalidation too, but it requires more manual wiring through their webhook payload.&lt;/p&gt;

&lt;p&gt;Sanity also wins on Portable Text. Contentful's Rich Text requires &lt;code&gt;@contentful/rich-text-react-renderer&lt;/code&gt;, which outputs relatively flat HTML. Sanity's Portable Text lets you map block types to custom React components — useful when editors need inline callouts, embedded components, or custom image crops inside prose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Contentful wins
&lt;/h2&gt;

&lt;p&gt;Contentful's content model tooling in the web UI is more approachable for non-developers. A content strategist can add a field without touching code. For teams where the CMS owner is not a developer, that matters.&lt;/p&gt;

&lt;p&gt;Contentful also has stronger built-in localization support at the field level, a more mature roles and permissions system, and a larger library of third-party integrations (AI assistants, DAM connectors, translation workflows). If you're building for a mid-market brand with a dedicated content ops team, Contentful's workflow features justify the cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Sanity wins
&lt;/h2&gt;

&lt;p&gt;Schema-in-repo is the right model for teams that treat content structure as part of the codebase. You get version control on your schema, environment-specific studios, and type safety from TypeGen. For a developer-led team, this is a significant productivity edge.&lt;/p&gt;

&lt;p&gt;The Studio is also genuinely customizable. Structure Builder, custom input components, and document badges let you shape the editorial experience for your specific content model — not the other way around.&lt;/p&gt;

&lt;p&gt;For Next.js specifically, the combination of GROQ's join capabilities, native &lt;code&gt;fetch&lt;/code&gt; cache integration, and Portable Text's component mapping makes Sanity the more productive platform for complex page architectures.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I decide on a new project
&lt;/h2&gt;

&lt;p&gt;Three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Who owns the content model long-term?&lt;/strong&gt; Developer-owned → Sanity. Marketing/content ops → Contentful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What's the team size and budget?&lt;/strong&gt; Under 5 editors on a lean budget → Sanity free tier covers it. Large team with compliance needs → Contentful's enterprise tier might already be the choice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How complex is the content graph?&lt;/strong&gt; Many cross-references, portable text with custom blocks, image focal points → Sanity's tooling handles this more gracefully.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Neither platform is wrong. They serve different principals. But for Next.js projects built by a small developer-led team, Sanity's schema-in-code model and GROQ's query expressiveness usually win on the work that actually matters.&lt;/p&gt;

</description>
      <category>sanitycms</category>
      <category>contentful</category>
      <category>nextjs</category>
      <category>groq</category>
    </item>
    <item>
      <title>Sanity vs Strapi vs Payload CMS: an honest comparison for 2026</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Wed, 20 May 2026 17:00:20 +0000</pubDate>
      <link>https://dev.to/nayankyada/sanity-vs-strapi-vs-payload-cms-an-honest-comparison-for-2026-44li</link>
      <guid>https://dev.to/nayankyada/sanity-vs-strapi-vs-payload-cms-an-honest-comparison-for-2026-44li</guid>
      <description>&lt;p&gt;Choosing between Sanity, Strapi, and Payload is one of the questions I get most often from teams starting a greenfield Next.js project. All three are legitimate headless CMS options in 2026, but they solve meaningfully different problems. This post is a direct comparison across the dimensions that actually matter in production: pricing, developer experience, schema modelling, image handling, and how hard it is to leave.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing and hosting model
&lt;/h2&gt;

&lt;p&gt;This is the sharpest difference between the three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sanity&lt;/strong&gt; is fully managed SaaS. You pay per seat on the Growth plan (around $15/editor/month at the time of writing) once you exceed the free tier. The CDN, the Studio, the asset pipeline — all hosted by Sanity. There's no infrastructure to run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strapi&lt;/strong&gt; is open-source and self-hosted by default. You run it on a VPS, Railway, Render, or your own Kubernetes cluster. The Community edition is free forever. Strapi Cloud exists and gives you a managed option, but most teams I've seen pick Strapi specifically &lt;em&gt;because&lt;/em&gt; they want control over where data lives — data-residency requirements, GDPR, or just cost certainty at scale. If you have 50 editors, Strapi won't invoice you $750/month for seats.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Payload&lt;/strong&gt; is also open-source and ships as a Node package that runs inside your own project. There's no separate Strapi-style server — Payload &lt;em&gt;is&lt;/em&gt; your backend. It connects to MongoDB or Postgres (Postgres support matured significantly in v3) and exposes a REST and GraphQL API plus a generated Admin UI. Payload Cloud exists for managed hosting, but the local dev story requires zero external services.&lt;/p&gt;

&lt;p&gt;Winner on cost at scale: &lt;strong&gt;Strapi or Payload&lt;/strong&gt; — neither charges per-seat, and both can run on hardware you already own.&lt;/p&gt;

&lt;p&gt;Winner for teams that don't want to run servers: &lt;strong&gt;Sanity&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Developer experience and schema modelling
&lt;/h2&gt;

&lt;p&gt;All three define schemas in code, but the ergonomics differ.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sanity schemas&lt;/strong&gt; live in TypeScript files and feed directly into Sanity Studio. The type system is mature, TypeGen generates fully-typed GROQ query results, and the Studio renders your schema as a polished editing UI without extra configuration. The constraint is that Sanity's content lake is a proprietary document store — you don't own the database, and your data model is shaped by Sanity's document/field primitives.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// sanity/schemas/article.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;defineField&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sanity&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineType&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;article&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;document&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;defineField&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;defineField&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;body&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;array&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;block&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Strapi schemas&lt;/strong&gt; are defined either through a GUI in the Content-Type Builder or by editing JSON files in &lt;code&gt;src/api/&amp;lt;name&amp;gt;/content-types/&lt;/code&gt;. The GUI is beginner-friendly but can produce messy diffs in version control. Teams that commit to code-first schema editing in Strapi end up with a solid workflow, but it takes discipline to avoid drift between local and production schema state. Relations in Strapi map to actual SQL joins, which is useful when you need to run arbitrary Postgres queries alongside the CMS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Payload schemas&lt;/strong&gt; feel the most like writing a database ORM. You define collections in TypeScript and Payload generates the Admin UI, REST endpoints, and database migrations automatically. If your team already knows Drizzle or Prisma, Payload's schema syntax will feel familiar. The tight Postgres integration means you can join CMS data with application tables in the same database — a real advantage for product teams building SaaS or e-commerce where content and business data coexist.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// payload/collections/Articles.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;CollectionConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payload&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Articles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CollectionConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;articles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;body&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;richText&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;author&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;relationship&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;relationTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;useAsTitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Winner on DX for content-rich sites: &lt;strong&gt;Sanity&lt;/strong&gt; — TypeGen plus GROQ plus the Studio is a complete, opinionated stack.&lt;/p&gt;

&lt;p&gt;Winner for Postgres-native product apps: &lt;strong&gt;Payload&lt;/strong&gt; — you get a CMS and an application database in one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Editor UX
&lt;/h2&gt;

&lt;p&gt;This is where Sanity has a durable lead. The Studio is the most polished editing interface of the three. Real-time collaboration, document presence, Portable Text with inline components, image hotspot editing, and a structure builder for custom navigation — all are production-grade and have been refined over years.&lt;/p&gt;

&lt;p&gt;Strapi's Admin UI is functional and non-technical editors can learn it quickly, but it feels like a form builder rather than a publishing tool. There's no equivalent to Portable Text; rich text relies on a Quill or Slate integration that varies by version.&lt;/p&gt;

&lt;p&gt;Payload's Admin UI is impressive given how recently it was rebuilt in v3, but it's still primarily developer-facing. For content-heavy teams where editors work daily, the gap with Sanity is real.&lt;/p&gt;

&lt;p&gt;Winner on editor UX: &lt;strong&gt;Sanity&lt;/strong&gt;, and it's not close.&lt;/p&gt;

&lt;h2&gt;
  
  
  Image pipeline
&lt;/h2&gt;

&lt;p&gt;Sanity's image CDN is one of the strongest arguments for the platform. Images are stored in the content lake and served via &lt;code&gt;cdn.sanity.io&lt;/code&gt; with on-the-fly transforms: width, height, format (WebP/AVIF), quality, crop, and hotspot-aware focal cropping. Combined with &lt;code&gt;next/image&lt;/code&gt; and a custom loader, you get automatic format negotiation and LCP-optimised delivery with minimal setup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/sanity-image-loader.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sanityLoader&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;ImageLoaderProps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?w=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;quality&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;auto=format&amp;amp;fit=crop`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strapi stores uploads locally or in an S3-compatible bucket via a provider plugin. There's no built-in image transform pipeline — you either run your own (Cloudinary plugin is common) or handle transforms at the Next.js layer. More moving parts, more configuration.&lt;/p&gt;

&lt;p&gt;Payload handles media similarly to Strapi: uploads go to disk or cloud storage, transforms require a plugin or an external service. The &lt;code&gt;@payloadcms/plugin-cloud-storage&lt;/code&gt; covers S3, GCS, and Azure, but image optimisation is still on you.&lt;/p&gt;

&lt;p&gt;Winner on image pipeline: &lt;strong&gt;Sanity&lt;/strong&gt; — the built-in CDN with on-the-fly transforms removes an entire category of infrastructure decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lock-in and portability
&lt;/h2&gt;

&lt;p&gt;This is the honest conversation clients avoid until it's too late.&lt;/p&gt;

&lt;p&gt;Sanity stores content in a proprietary NDJSON document store. You can export all data via the API, and the format is readable, but your GROQ queries and schema primitives (especially Portable Text) don't map directly to any other CMS. Migrating away is a project, not an afternoon.&lt;/p&gt;

&lt;p&gt;Strapi and Payload both use standard SQL or MongoDB. Your data lives in tables or collections you own. Moving from Strapi to Payload (or to a raw Postgres app) is a SQL migration, not a CMS-to-CMS content export. That's a meaningful difference if you're building something long-lived and want optionality.&lt;/p&gt;

&lt;p&gt;Winner on portability: &lt;strong&gt;Strapi or Payload&lt;/strong&gt; — you own the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  When each one wins
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pick Sanity&lt;/strong&gt; when editor experience and image delivery are the priority — marketing sites, editorial platforms, content-heavy agencies. The managed CDN, Studio polish, and TypeGen workflow justify the seat cost for most content teams.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick Strapi&lt;/strong&gt; when data residency, self-hosting, or seat-count economics are non-negotiable. Enterprise clients with GDPR requirements and 30+ editors will often mandate self-hosted; Strapi is the most mature option in that lane.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick Payload&lt;/strong&gt; when you're building a product app and want your CMS and application database in the same Postgres instance. Authentication, collections, and business data unified under one Node app, with a generated Admin UI included. It's the option that most blurs the line between CMS and application framework.&lt;/p&gt;

</description>
      <category>sanity</category>
      <category>strapi</category>
      <category>payloadcms</category>
      <category>headlesscms</category>
    </item>
    <item>
      <title>Sanity CMS website cost in 2026: what founders actually pay</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Wed, 20 May 2026 17:00:19 +0000</pubDate>
      <link>https://dev.to/nayankyada/sanity-cms-website-cost-in-2026-what-founders-actually-pay-2d3n</link>
      <guid>https://dev.to/nayankyada/sanity-cms-website-cost-in-2026-what-founders-actually-pay-2d3n</guid>
      <description>&lt;p&gt;Budgeting for a Sanity CMS website is harder than it should be. The platform itself is free to start, which makes early quotes feel reassuring — then the final invoice lands and founders wonder where the number came from. This post breaks down every real cost driver so you can scope a project honestly before you hire anyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What drives sanity cms website cost more than anything else
&lt;/h2&gt;

&lt;p&gt;The licence fee is almost never the problem. Sanity's free tier covers most small sites comfortably (up to three users, generous API limits). Growth and custom plan pricing starts to matter around 10+ editors or high-traffic content APIs, but even then you are looking at a few hundred dollars a month at most — not the dominant line item.&lt;/p&gt;

&lt;p&gt;What actually drives cost is the work required to model your content, build the editing experience your team will use every day, and connect the site to everything else your business runs on. Let me walk through each layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Content modelling&lt;/strong&gt; is the architectural work done before a single page is built. A developer has to define what a "product", a "case study", or a "press release" looks like as structured data — what fields it has, what relationships it holds, what validation rules prevent editors from publishing broken content. A simple marketing site might need five or six document types. A content platform with tags, authors, series, and gated posts might need twenty, each with their own rules. More document types means more hours, and mistakes here are expensive to fix later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Page count and template variety&lt;/strong&gt; compound the modelling work. Twelve pages built from three templates costs far less than twelve pages each with a unique layout. Before you get a quote, list your pages and honestly count how many are genuinely different from each other. Agencies and freelancers price template variety, not raw page count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The editor experience&lt;/strong&gt; is underquoted and then complained about. Sanity Studio is highly customisable — you can build a clean, opinionated interface that guides your content team, or you can ship the default and watch editors call you weekly. A well-structured studio with filtered views, conditional fields, and sensible document ordering takes real time to build. Budget for it. It pays back in reduced support requests from your team.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integrations are where budgets stretch
&lt;/h2&gt;

&lt;p&gt;Sanity stores your content. It does not handle payments, email, search, or video — and most real products need at least one of those.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Stripe&lt;/strong&gt;: Connecting a product catalogue in Sanity to Stripe for checkout adds meaningful complexity. You need to decide what lives in Sanity (marketing copy, images, variant descriptions) versus what lives in Stripe (prices, inventory, webhooks). Scoping that boundary alone is a half-day conversation. Building it is typically two to four days of development.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;SendGrid&lt;/strong&gt;: Triggered emails off content events — a new post published, a form submitted, a membership renewed — require route handlers that listen for Sanity webhooks and call SendGrid's API. Straightforward in isolation, but each trigger adds test coverage and edge cases.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Algolia&lt;/strong&gt;: Full-text search across a large Sanity content library almost always lands on Algolia. You need a sync pipeline that pushes content to Algolia when it changes, an index schema that matches your search UX, and a search component on the front end. Expect three to five days for a well-tuned integration.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Mux&lt;/strong&gt;: Video-heavy sites — course platforms, media brands — use Mux for adaptive streaming. Uploading from Sanity Studio via a custom asset source, storing the Mux playback ID in your schema, and rendering a player with the right poster frame is around two to three days of work.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each integration is a multiplier, not an add-on. If you want Stripe and Algolia and Mux, those do not add up linearly — shared infrastructure, authentication patterns, and error handling overlap in ways that skilled developers manage efficiently, but the work is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Realistic cost ranges for three common project types
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Small marketing site&lt;/strong&gt; — five to eight pages, one blog section, one or two editors, no integrations beyond a contact form and basic analytics.&lt;/p&gt;

&lt;p&gt;Design-to-launch freelance rate: £4,000–£9,000 / $5,000–$12,000. Timeline: three to six weeks. Ongoing hosting: £20–£60/month (Vercel hobby or pro, Sanity free tier). This is the profile where Sanity's low entry cost genuinely shines. You get a clean editorial experience and a fast, modern front end at a sensible budget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mid-size content platform&lt;/strong&gt; — blog with authors and tags, gated content, newsletter integration, Algolia search, twenty-plus document types, up to ten editors.&lt;/p&gt;

&lt;p&gt;Freelance or small agency rate: £18,000–£40,000 / $22,000–$50,000. Timeline: eight to sixteen weeks. Ongoing hosting: £100–£300/month including Sanity Growth plan, Vercel Pro, and Algolia's starter tier. The range is wide because content modelling complexity and design fidelity vary enormously at this tier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Complex multi-locale build&lt;/strong&gt; — multiple languages with separate editorial workflows, market-specific pricing via Stripe, Mux video library, custom Sanity Studio plugins, five-plus integrations, fifteen-plus editors, compliance requirements.&lt;/p&gt;

&lt;p&gt;Agency rate: £70,000–£180,000 / $85,000–$220,000. Timeline: four to eight months. Ongoing hosting and tooling: £500–£2,000/month. Internationalisation alone — routing, translation workflows, locale-specific content fallbacks — adds weeks of development that most initial scopes underestimate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Editor training and ongoing maintenance
&lt;/h2&gt;

&lt;p&gt;Training is skipped in more proposals than I can count. A Sanity Studio that took six weeks to build still needs two to four hours of structured walkthrough for your editorial team, plus documentation written for non-technical staff. Budget £400–£1,200 for this. It prevents three months of avoidable support tickets.&lt;/p&gt;

&lt;p&gt;Maintenance is a separate question from hosting. Dependency updates, Sanity schema migrations when your content needs change, new page templates as your business grows — that work is either retained on a monthly contract (£400–£1,500/month is a common range for a freelance retainer) or quoted project by project. Neither is wrong, but know which model you are agreeing to before you sign.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to get a quote you can actually trust
&lt;/h2&gt;

&lt;p&gt;Before approaching a developer or agency, write down: how many distinct page layouts you need, which third-party tools your site must talk to, how many people will edit content, and whether you need multiple languages. That list turns a vague conversation into a scopeable brief. Developers who quote from a brief are giving you a number they can defend. Developers who quote from a thirty-minute call are guessing — and you will pay for the gap.&lt;/p&gt;

</description>
      <category>sanitycms</category>
      <category>headlesscms</category>
      <category>webdevelopmentcost</category>
      <category>clientguide</category>
    </item>
    <item>
      <title>INP for React Apps: Profiling and Eliminating Long Tasks</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Wed, 20 May 2026 16:59:32 +0000</pubDate>
      <link>https://dev.to/nayankyada/inp-for-react-apps-profiling-and-eliminating-long-tasks-2ml1</link>
      <guid>https://dev.to/nayankyada/inp-for-react-apps-profiling-and-eliminating-long-tasks-2ml1</guid>
      <description>&lt;p&gt;INP (Interaction to Next Paint) measures &lt;strong&gt;how quickly your UI responds&lt;/strong&gt; after a user interacts.&lt;br&gt;
If a click, tap, or keypress is followed by a noticeable delay, you’ll feel it — and so will your users.&lt;/p&gt;

&lt;p&gt;INP is now the key responsiveness metric in Core Web Vitals, and it’s one of the most common issues on React apps that ship too much JavaScript.&lt;/p&gt;

&lt;h2&gt;
  
  
  What INP actually measures (in plain terms)
&lt;/h2&gt;

&lt;p&gt;When a user interacts, the browser has to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;run your event handler,&lt;/li&gt;
&lt;li&gt;run any state updates and rendering work,&lt;/li&gt;
&lt;li&gt;paint the next frame.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;INP captures the time from interaction to the next paint for &lt;strong&gt;the worst interactions&lt;/strong&gt; users experience (within a page view).&lt;/p&gt;

&lt;h3&gt;
  
  
  Targets (baseline)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Good: &lt;strong&gt;≤ 200ms&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Needs improvement: &lt;strong&gt;200–500ms&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Poor: &lt;strong&gt;&amp;gt; 500ms&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The main causes of bad INP in React apps
&lt;/h2&gt;

&lt;p&gt;In most apps, INP is bad because of one or more of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Long tasks&lt;/strong&gt; (main thread blocked for &amp;gt;50ms)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render storms&lt;/strong&gt; (too many components re-rendering)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heavy work inside event handlers&lt;/strong&gt; (sync parsing, sorting, filtering)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-party scripts&lt;/strong&gt; (analytics, chat widgets, tag managers)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Too much JS shipped&lt;/strong&gt; (hydration costs + runtime overhead)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You don’t “optimize INP” by tweaking one thing — you reduce main-thread work and make updates cheaper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 0: Confirm you really have an INP problem
&lt;/h2&gt;

&lt;p&gt;Start with &lt;strong&gt;field data&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Search Console’s Core Web Vitals report (pattern-level)&lt;/li&gt;
&lt;li&gt;RUM if you have it (best)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then use &lt;strong&gt;lab tools&lt;/strong&gt; to reproduce:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chrome DevTools Performance recording&lt;/li&gt;
&lt;li&gt;React DevTools Profiler&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Find long tasks (your #1 enemy)
&lt;/h2&gt;

&lt;p&gt;If the main thread is blocked, the browser can’t paint.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to spot them
&lt;/h3&gt;

&lt;p&gt;In a Performance recording:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Look for long yellow blocks (scripting).&lt;/li&gt;
&lt;li&gt;Zoom into interactions and check what runs right after the input event.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you see repeated long tasks, you’ve found your INP root cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Make event handlers “light”
&lt;/h2&gt;

&lt;p&gt;Event handlers should ideally:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;update state,&lt;/li&gt;
&lt;li&gt;schedule work,&lt;/li&gt;
&lt;li&gt;and return quickly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Common anti-patterns
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Doing expensive filtering/sorting synchronously on click&lt;/li&gt;
&lt;li&gt;Parsing large JSON payloads during input&lt;/li&gt;
&lt;li&gt;Building huge arrays/objects during a scroll/typing event&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Fix patterns
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Precompute when possible (outside the interaction)&lt;/li&gt;
&lt;li&gt;Debounce expensive work triggered by typing&lt;/li&gt;
&lt;li&gt;Chunk big work into smaller pieces&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3: Reduce React re-render costs
&lt;/h2&gt;

&lt;p&gt;Many INP problems are simply “too much renders happen per interaction”.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I check first
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Are we passing new objects/functions every render?&lt;/li&gt;
&lt;li&gt;Are lists re-rendering on every keystroke?&lt;/li&gt;
&lt;li&gt;Is global state causing whole pages to update?&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Fix patterns that consistently help
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Memoize&lt;/strong&gt; hot components (only where it matters)&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;stable props&lt;/strong&gt; (avoid &lt;code&gt;{}&lt;/code&gt; and &lt;code&gt;() =&amp;gt; {}&lt;/code&gt; inline for hot paths)&lt;/li&gt;
&lt;li&gt;Split state: keep “typing state” local, not global&lt;/li&gt;
&lt;li&gt;Virtualize big lists/grids&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal isn’t to “memo everything”. The goal is to stop re-rendering 200 components when the user clicks one button.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Reduce hydration + client JS on content pages
&lt;/h2&gt;

&lt;p&gt;If your page is mostly content (blog posts), you usually don’t need much JS.&lt;/p&gt;

&lt;h3&gt;
  
  
  Best lever
&lt;/h3&gt;

&lt;p&gt;Avoid turning layout/typography into client components.&lt;/p&gt;

&lt;p&gt;Ship interaction only where needed (search box, filters, forms), and keep everything else server-rendered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Defer third-party scripts (they often dominate INP)
&lt;/h2&gt;

&lt;p&gt;Third-party scripts can easily:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;add long tasks,&lt;/li&gt;
&lt;li&gt;create layout thrash,&lt;/li&gt;
&lt;li&gt;or block the main thread during interaction.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Practical strategy
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Defer until after first interaction or idle&lt;/li&gt;
&lt;li&gt;Load only on routes that need it&lt;/li&gt;
&lt;li&gt;Remove anything you don’t use weekly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most teams keep scripts forever. INP improves fast when you treat scripts like dependencies with a cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Use a repeatable INP “playbook”
&lt;/h2&gt;

&lt;p&gt;This is my default workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Identify a bad interaction (field data or user report).&lt;/li&gt;
&lt;li&gt;Reproduce in DevTools.&lt;/li&gt;
&lt;li&gt;Find the longest task after the input event.&lt;/li&gt;
&lt;li&gt;Reduce work in the handler.&lt;/li&gt;
&lt;li&gt;Reduce re-renders triggered by that state update.&lt;/li&gt;
&lt;li&gt;Re-test and confirm the long task is gone.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Quick checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Event handlers return quickly&lt;/li&gt;
&lt;li&gt;Expensive work is deferred/chunked&lt;/li&gt;
&lt;li&gt;Large lists are virtualized&lt;/li&gt;
&lt;li&gt;Hot components are memoized appropriately&lt;/li&gt;
&lt;li&gt;Client JS is minimized on content routes&lt;/li&gt;
&lt;li&gt;Third-party scripts are deferred/audited&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>performance</category>
      <category>react</category>
      <category>corewebvitals</category>
    </item>
    <item>
      <title>Why Core Web Vitals Matter (and How I Improve Them)</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Wed, 20 May 2026 16:59:31 +0000</pubDate>
      <link>https://dev.to/nayankyada/why-core-web-vitals-matter-and-how-i-improve-them-pj3</link>
      <guid>https://dev.to/nayankyada/why-core-web-vitals-matter-and-how-i-improve-them-pj3</guid>
      <description>&lt;p&gt;Core Web Vitals are Google’s &lt;strong&gt;real‑user performance signals&lt;/strong&gt; for how a page &lt;em&gt;feels&lt;/em&gt;.&lt;br&gt;
They’re not just “speed scores” — they influence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SEO&lt;/strong&gt; (page experience is a ranking signal),&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;conversion rate&lt;/strong&gt; (slow, janky pages lose users),&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;trust&lt;/strong&gt; (fast sites feel higher quality).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re building a marketing site or content platform, improving Core Web Vitals is one of the highest-ROI technical tasks you can do.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are Core Web Vitals?
&lt;/h2&gt;

&lt;p&gt;Today, the core metrics are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LCP (Largest Contentful Paint)&lt;/strong&gt;: how quickly the main content becomes visible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;INP (Interaction to Next Paint)&lt;/strong&gt;: how responsive the page is to user input.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLS (Cumulative Layout Shift)&lt;/strong&gt;: how stable the layout is while loading.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are measured using &lt;strong&gt;real user data&lt;/strong&gt; (field data), not only synthetic tests.&lt;/p&gt;

&lt;h3&gt;
  
  
  Target thresholds (what “good” means)
&lt;/h3&gt;

&lt;p&gt;Use these as your baseline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LCP&lt;/strong&gt;: good ≤ 2.5s, needs improvement 2.5–4.0s, poor &amp;gt; 4.0s&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;INP&lt;/strong&gt;: good ≤ 200ms, needs improvement 200–500ms, poor &amp;gt; 500ms&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLS&lt;/strong&gt;: good ≤ 0.1, needs improvement 0.1–0.25, poor &amp;gt; 0.25&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you hit “good” consistently, you’ll usually see improved crawl efficiency, higher engagement, and better rankings over time (especially in competitive queries where all content is similar).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why they matter for SEO (the practical view)
&lt;/h2&gt;

&lt;p&gt;Google has said for years: &lt;strong&gt;content relevance wins&lt;/strong&gt;. That’s still true.&lt;br&gt;
But on the margin — when two pages are equally relevant — performance can be the difference.&lt;/p&gt;

&lt;p&gt;Core Web Vitals matter most when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;your niche is competitive (tooling, agencies, SaaS, ecommerce),&lt;/li&gt;
&lt;li&gt;users bounce quickly (landing pages, blog posts),&lt;/li&gt;
&lt;li&gt;your pages are media-heavy (images, embeds, third-party scripts).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also: good UX improves how users behave (time on site, browsing depth). Those aren’t direct ranking factors in a simple way, but they correlate strongly with sites that perform well in search.&lt;/p&gt;

&lt;h2&gt;
  
  
  The “field vs lab” trap
&lt;/h2&gt;

&lt;p&gt;Many teams ship optimisations that look great in Lighthouse but do nothing for real users.&lt;br&gt;
That’s because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lighthouse runs on a simulated device/network.&lt;/li&gt;
&lt;li&gt;Core Web Vitals in Search Console come from actual users across devices, geos, and connection types.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The right workflow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use &lt;strong&gt;field data&lt;/strong&gt; to find the real problems.&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;lab tools&lt;/strong&gt; to reproduce and fix them.&lt;/li&gt;
&lt;li&gt;Validate again in field data.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How to measure Core Web Vitals (what I use)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Field data (real users)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Google Search Console (Core Web Vitals report)&lt;/strong&gt;: broad view by template.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CrUX&lt;/strong&gt; (Chrome UX Report): site-level + page-level trends.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RUM&lt;/strong&gt; (real-user monitoring): best if you want per-route and per-release tracking.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Lab data (debugging)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lighthouse&lt;/strong&gt;: quick checks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chrome DevTools Performance&lt;/strong&gt;: deep INP/debug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebPageTest&lt;/strong&gt;: waterfalls, filmstrip, CDN/cache behaviour.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you can’t measure reliably, you can’t improve reliably.&lt;/p&gt;

&lt;h2&gt;
  
  
  LCP: the fastest wins (and the common causes)
&lt;/h2&gt;

&lt;p&gt;LCP is usually dominated by one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a big hero image,&lt;/li&gt;
&lt;li&gt;a large heading block (custom fonts),&lt;/li&gt;
&lt;li&gt;a server response delay (TTFB),&lt;/li&gt;
&lt;li&gt;render-blocking CSS/JS.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Fixes that consistently help LCP
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Optimise the LCP element&lt;/strong&gt;
Make the hero image properly sized, compressed, and served via CDN.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preload the right resources&lt;/strong&gt;
Fonts and the actual LCP image should load early.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reduce TTFB&lt;/strong&gt;
Cache aggressively, avoid expensive server work on first request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid heavy client-side hydration&lt;/strong&gt;
Keep initial render simple; defer non-critical scripts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In Next.js, the biggest LCP improvements usually come from &lt;strong&gt;image strategy + caching + avoiding unnecessary client components&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  INP: responsiveness (the one many teams ignore)
&lt;/h2&gt;

&lt;p&gt;INP measures how quickly the UI updates after an interaction (click, tap, type).&lt;br&gt;
Bad INP usually comes from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;long tasks on the main thread,&lt;/li&gt;
&lt;li&gt;expensive React re-renders,&lt;/li&gt;
&lt;li&gt;heavy third-party scripts,&lt;/li&gt;
&lt;li&gt;too much JS on the initial page.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Fixes that move INP
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reduce bundle size&lt;/strong&gt;: ship less JS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split work&lt;/strong&gt;: move expensive logic to the server or a worker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memoize thoughtfully&lt;/strong&gt;: avoid re-render storms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Defer third-party&lt;/strong&gt;: load analytics/chat widgets after interaction or idle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your site is content-heavy (blog), INP improvements often come from simply reducing client JS.&lt;/p&gt;

&lt;h2&gt;
  
  
  CLS: layout stability (easy to fix, big UX win)
&lt;/h2&gt;

&lt;p&gt;CLS happens when elements move after rendering.&lt;br&gt;
The common causes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;images without dimensions,&lt;/li&gt;
&lt;li&gt;fonts swapping late (FOIT/FOUT),&lt;/li&gt;
&lt;li&gt;injected banners/popups,&lt;/li&gt;
&lt;li&gt;components that load content without reserved space.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Fixes for CLS
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Always reserve space for images/media.&lt;/li&gt;
&lt;li&gt;Avoid late-injecting UI above content.&lt;/li&gt;
&lt;li&gt;Use stable font loading strategies.&lt;/li&gt;
&lt;li&gt;Skeletons should match final layout.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CLS is one of the easiest vitals to improve — and users notice immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  A simple checklist I apply on every site
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Images&lt;/strong&gt;: correct size, modern formats, CDN, don’t ship huge assets to mobile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caching&lt;/strong&gt;: CDN + edge where possible; avoid dynamic work on every request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JavaScript budget&lt;/strong&gt;: keep initial JS minimal, defer non-critical scripts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fonts&lt;/strong&gt;: preload only what you need, limit weights, avoid blocking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-party&lt;/strong&gt;: audit everything; most scripts are performance tax.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What results to expect
&lt;/h2&gt;

&lt;p&gt;When you improve Core Web Vitals, the most common outcomes are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;better engagement (scroll depth, time on page),&lt;/li&gt;
&lt;li&gt;improved conversion rate on landing pages,&lt;/li&gt;
&lt;li&gt;more consistent rankings (especially where competitors are similar).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the best part: these gains compound. Every future page you publish benefits from the same performance foundation.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>performance</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Sanity vs WordPress headless CMS: when headless actually beats traditional</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Wed, 20 May 2026 16:58:09 +0000</pubDate>
      <link>https://dev.to/nayankyada/sanity-vs-wordpress-headless-cms-when-headless-actually-beats-traditional-3kc5</link>
      <guid>https://dev.to/nayankyada/sanity-vs-wordpress-headless-cms-when-headless-actually-beats-traditional-3kc5</guid>
      <description>&lt;p&gt;Choosing between Sanity and WordPress as a headless CMS is rarely a pure technical question. It involves editor habits, hosting budgets, plugin dependencies, and how much custom work you want to own long-term. I migrated a mid-size marketing site — 400 pages, 3 content editors, ~180k monthly visits — from WordPress (with WPGraphQL) to Sanity + Next.js earlier this year. The numbers below come from that project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Editor UX: familiarity vs fit-for-purpose
&lt;/h2&gt;

&lt;p&gt;WordPress's editor is genuinely good for people who learned content editing on WordPress. Gutenberg's block library is wide, the media library is tactile, and non-technical editors can add pages without any developer help. That's a real advantage. When I've handed off WordPress sites to marketing teams who already know the platform, training time is near zero.&lt;/p&gt;

&lt;p&gt;Sanity Studio is a different experience. The editing surface is structured rather than visual — you fill in typed fields rather than assembling blocks freehand. Editors who come from a publishing or ops background adapt quickly because it feels more like a form than a page canvas. Editors who came from Gutenberg needed about two weeks to stop reaching for the block inserter. The payoff is that the content model is explicit: a "hero" is always a hero, a "testimonial" always has a quote and an author reference, and GROQ queries stay predictable.&lt;/p&gt;

&lt;p&gt;For the migration project, I ran both studios in parallel for three weeks. After go-live, none of the three editors asked to go back. The structured model reduced publishing errors — they'd previously broken layouts by pasting rich text with inline styles into heading fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance: the gap is real
&lt;/h2&gt;

&lt;p&gt;On the old WordPress + WPGraphQL stack, the site averaged 2.8 s LCP on mobile (Lighthouse CI, median of 30 runs, same test pages). After migrating to Sanity + Next.js App Router with ISR, the same pages averaged 1.1 s LCP. TTFB dropped from 480 ms to 60 ms once pages were edge-cached on Vercel.&lt;/p&gt;

&lt;p&gt;WordPress headless is not inherently slow — WPGraphQL is capable, and a well-cached WordPress API can perform well. But WordPress still loads PHP, initialises a plugin stack, and hits MySQL on cache misses. Sanity's CDN-backed Content Delivery API returns JSON from an edge node. The ceiling is higher with Sanity once you invest in the query layer.&lt;/p&gt;

&lt;p&gt;CLS was the other win. WordPress's media library stores dimensions inconsistently — plugins resize images and lose the originals. Pre-calculating crop dimensions from Sanity's asset metadata let me pass explicit &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; to &lt;code&gt;next/image&lt;/code&gt; on every image, eliminating layout shift entirely on image-heavy pages. That work is described in detail in a separate post; the short version is that Sanity stores the original dimensions in the asset document and you can project them at query time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Developer experience
&lt;/h2&gt;

&lt;p&gt;WordPress headless requires two distinct systems: the WordPress install (PHP, MySQL, plugins, theme files even if unused) and the front-end framework. You maintain both. WPGraphQL adds a third dependency. Schema changes require ACF or a custom plugin, which means PHP. TypeScript types for the API response are hand-written or generated from an introspection query that drifts the moment a plugin updates.&lt;/p&gt;

&lt;p&gt;Sanity's developer experience is TypeScript-native end to end. Schemas are TypeScript objects. Sanity TypeGen generates types from those schemas, so your GROQ query results are typed at compile time. The Studio is a React app you extend with your own components. All of this lives in your repo — one deployment target per environment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// sanity/schemas/post.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;defineField&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sanity&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;postSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineType&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;post&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Post&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;document&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;defineField&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;defineField&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;slug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;slug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;defineField&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;publishedAt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;datetime&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;defineField&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;body&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;array&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;block&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;// npx sanity typegen generate → PostDocument type available in queries&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The friction point with Sanity is the content model itself. WordPress gives you posts and pages on day one with no schema design required. With Sanity you make decisions upfront — what document types do you need, how do references work, what goes in a global settings document. That's an upfront cost of roughly 8–12 hours on a medium-complexity site, but it pays back quickly in query predictability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plugin ecosystem: WordPress wins, with caveats
&lt;/h2&gt;

&lt;p&gt;WordPress has ~60,000 plugins. Sanity has an ecosystem of official and community plugins (Mux video, Cloudinary, desk tools, internationalization) but it's much smaller. If you need a specific integration — a particular payment gateway, a niche LMS, a regional shipping provider — WordPress probably has a plugin. Sanity probably requires custom code.&lt;/p&gt;

&lt;p&gt;The caveat is that WordPress plugins have a security and maintenance overhead that compounds. The migration project had 31 active plugins. Twelve of them existed to work around Gutenberg limitations or patch other plugins. Two had unfixed CVEs. Trimming to Sanity + a handful of focused API integrations reduced the attack surface substantially and removed a recurring maintenance cost the client had been absorbing as "normal".&lt;/p&gt;

&lt;h2&gt;
  
  
  Hosting cost and total cost of ownership
&lt;/h2&gt;

&lt;p&gt;WordPress hosting with WP Engine (Growth plan) was running the client £65/month. Vercel Pro for the Next.js front-end added £18/month. Sanity's free tier covered their usage (under 10k API calls/day). Total after migration: £18/month ongoing, with Sanity free, versus £65/month before.&lt;/p&gt;

&lt;p&gt;That's not always the outcome. High-traffic WordPress sites on shared hosting cost less than Vercel Pro. And the migration itself took 6 weeks of developer time — a one-time cost that only makes sense if you're planning to hold the site for 18+ months or if the WordPress maintenance overhead is already significant. I won't pretend Sanity is the cheaper option for a five-page brochure site that's been running on a £10/month shared host for three years.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to stay on WordPress
&lt;/h2&gt;

&lt;p&gt;WordPress headless makes sense when your team already runs WordPress at scale, when you have heavy WooCommerce dependencies, or when the editor team is large and deeply invested in Gutenberg. WordPress's user management, multisite, and comment/membership ecosystems are genuinely hard to replicate in a custom Sanity setup without third-party services.&lt;/p&gt;

&lt;p&gt;WordPress also wins for projects with a tight timeline and a developer who knows the stack. A skilled WordPress developer can ship a new marketing site faster than a Sanity project if the Sanity content model needs to be designed from scratch.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Sanity headless is the right call
&lt;/h2&gt;

&lt;p&gt;Sanity earns its place when the content model is complex and needs to stay consistent across multiple front-ends (web, app, email), when the team wants TypeScript discipline across the CMS and the front-end, or when long-term maintenance cost matters more than setup speed. The performance ceiling is also meaningfully higher — not because Sanity's API is magic, but because the delivery layer is designed for edge caching from the start, whereas WordPress was designed for server rendering and has been adapted for headless use after the fact.&lt;/p&gt;

&lt;p&gt;The migration numbers are real: 60% reduction in LCP, 87% reduction in TTFB on cached pages, and a hosting bill that dropped by more than half. That's a strong case — but it took six weeks to get there, and it would not have been worth it on a shorter-lived project.&lt;/p&gt;

</description>
      <category>sanity</category>
      <category>wordpress</category>
      <category>headlesscms</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>How to Fix LCP on Image-Heavy Pages (Next.js Patterns That Work)</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Wed, 20 May 2026 16:56:16 +0000</pubDate>
      <link>https://dev.to/nayankyada/how-to-fix-lcp-on-image-heavy-pages-nextjs-patterns-that-work-37h1</link>
      <guid>https://dev.to/nayankyada/how-to-fix-lcp-on-image-heavy-pages-nextjs-patterns-that-work-37h1</guid>
      <description>&lt;p&gt;If your page is image-heavy (portfolio, case studies, blogs with hero media), &lt;strong&gt;LCP&lt;/strong&gt; is almost always dominated by one thing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a hero image,&lt;/li&gt;
&lt;li&gt;a gallery grid above the fold,&lt;/li&gt;
&lt;li&gt;or an image carousel that loads too much too early.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post is the exact workflow I use to get &lt;strong&gt;LCP &amp;lt; 2.5s&lt;/strong&gt; reliably on real devices — not just in Lighthouse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 0: Don’t guess — find the true LCP element
&lt;/h2&gt;

&lt;p&gt;Before you change anything, identify what’s actually being reported as LCP.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where to check
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chrome DevTools → Performance&lt;/strong&gt;: record load and look for “Largest Contentful Paint”.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lighthouse&lt;/strong&gt;: use it to get hints, but verify in DevTools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Field data&lt;/strong&gt;: Search Console Core Web Vitals groups by URL pattern; it tells you &lt;em&gt;where&lt;/em&gt; to focus.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the LCP element is a &lt;strong&gt;heading&lt;/strong&gt;, your issues are often &lt;strong&gt;fonts, CSS, or hydration&lt;/strong&gt;.&lt;br&gt;&lt;br&gt;
If the LCP element is an &lt;strong&gt;image&lt;/strong&gt;, your issues are usually &lt;strong&gt;bytes + priority + caching&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The main LCP killers on image-heavy pages
&lt;/h2&gt;

&lt;p&gt;In practice, LCP gets worse because of:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Slow TTFB&lt;/strong&gt; (server work, no caching, cold starts)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Too many bytes&lt;/strong&gt; (oversized hero images, unoptimised formats)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bad loading order&lt;/strong&gt; (the hero image isn’t requested early)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render-blocking work&lt;/strong&gt; (fonts, CSS, heavy JS before paint)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You fix LCP by addressing these &lt;em&gt;in order&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  1) Reduce TTFB (because LCP can’t start without HTML)
&lt;/h2&gt;

&lt;p&gt;Even perfect images won’t help if the server responds slowly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Patterns that work
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache full pages&lt;/strong&gt; where possible (edge/CDN)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid per-request expensive work&lt;/strong&gt; on marketing pages (DB calls, heavy Markdown parsing)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use static generation&lt;/strong&gt; for content pages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move non-critical work to after render&lt;/strong&gt; (analytics, tracking, personalization)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Quick sanity check
&lt;/h3&gt;

&lt;p&gt;If TTFB is &amp;gt; 800ms for most users, fix that first.&lt;/p&gt;

&lt;h2&gt;
  
  
  2) Ship the right image bytes (format + dimensions)
&lt;/h2&gt;

&lt;p&gt;Most LCP wins come from sending fewer bytes earlier.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rules I follow
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never&lt;/strong&gt; serve a 2500px image to a 390px viewport.&lt;/li&gt;
&lt;li&gt;Prefer &lt;strong&gt;AVIF/WebP&lt;/strong&gt; when available.&lt;/li&gt;
&lt;li&gt;Keep the LCP image &lt;strong&gt;as small as possible&lt;/strong&gt; without looking soft.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Practical targets
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Hero image often can be &lt;strong&gt;100–250 KB&lt;/strong&gt; (sometimes less) and still look great.&lt;/li&gt;
&lt;li&gt;Avoid huge PNGs for photos (they’re almost always wrong).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3) Make the LCP request happen immediately
&lt;/h2&gt;

&lt;p&gt;If the browser doesn’t request the hero image early, LCP will suffer even if the image is small.&lt;/p&gt;

&lt;h3&gt;
  
  
  Next.js patterns that work
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Use &lt;code&gt;next/image&lt;/code&gt; for responsive sizing and modern formats.&lt;/li&gt;
&lt;li&gt;For the hero image, set &lt;strong&gt;priority&lt;/strong&gt; so it’s fetched early.&lt;/li&gt;
&lt;li&gt;Ensure the LCP image is not hidden behind a lazy-loaded carousel or a conditional render.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Common mistake
&lt;/h3&gt;

&lt;p&gt;“Lazy-loading everything” feels like the right move but can backfire: the hero should be &lt;strong&gt;eager&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4) Use &lt;code&gt;sizes&lt;/code&gt; correctly (this is where most teams fail)
&lt;/h2&gt;

&lt;p&gt;If you don’t set &lt;code&gt;sizes&lt;/code&gt;, the browser might choose a larger &lt;code&gt;srcset&lt;/code&gt; candidate than needed.&lt;/p&gt;

&lt;p&gt;The goal is simple: tell the browser the rendered width at each breakpoint.&lt;/p&gt;

&lt;p&gt;Example thinking (not copy/paste):&lt;br&gt;&lt;br&gt;
If your hero is full-width on mobile, but constrained to 720px on desktop, &lt;code&gt;sizes&lt;/code&gt; should reflect that.&lt;/p&gt;

&lt;h2&gt;
  
  
  5) Preload what matters (but don’t preload everything)
&lt;/h2&gt;

&lt;p&gt;Preloading is powerful when used for the &lt;strong&gt;one&lt;/strong&gt; resource that blocks LCP.&lt;/p&gt;

&lt;p&gt;Good preload candidates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the hero image (if it’s LCP)&lt;/li&gt;
&lt;li&gt;the critical font file (if heading/text is LCP)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bad preload candidates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;multiple gallery images&lt;/li&gt;
&lt;li&gt;below-the-fold media&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6) Avoid above-the-fold image “work”
&lt;/h2&gt;

&lt;p&gt;Some patterns look nice but destroy LCP:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;blur + heavy filters&lt;/li&gt;
&lt;li&gt;client-side parallax effects on the hero&lt;/li&gt;
&lt;li&gt;large background images applied via CSS that aren’t optimised&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want motion, apply it after first paint.&lt;/p&gt;

&lt;h2&gt;
  
  
  7) Reduce JS before paint (especially client components)
&lt;/h2&gt;

&lt;p&gt;On image-heavy pages, you often still lose LCP because the main thread is busy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;large client bundles,&lt;/li&gt;
&lt;li&gt;third-party scripts,&lt;/li&gt;
&lt;li&gt;hydration costs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What I do
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Keep the hero and initial layout server-rendered.&lt;/li&gt;
&lt;li&gt;Move interactive widgets below the fold or load them after interaction.&lt;/li&gt;
&lt;li&gt;Audit third-party scripts (most are a tax).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A repeatable debugging workflow (use this every time)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Identify the true LCP element.&lt;/li&gt;
&lt;li&gt;Check TTFB and caching.&lt;/li&gt;
&lt;li&gt;Check image bytes and format.&lt;/li&gt;
&lt;li&gt;Ensure the hero request is early (priority/order).&lt;/li&gt;
&lt;li&gt;Fix &lt;code&gt;sizes&lt;/code&gt; so the browser chooses the right candidate.&lt;/li&gt;
&lt;li&gt;Reduce JS/third-party blocking.&lt;/li&gt;
&lt;li&gt;Validate in field data.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Quick checklist for image-heavy pages
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;LCP element is known (image vs text)&lt;/li&gt;
&lt;li&gt;HTML response is cached&lt;/li&gt;
&lt;li&gt;Hero image is compressed + correct dimensions&lt;/li&gt;
&lt;li&gt;Hero image is requested early (priority)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sizes&lt;/code&gt; is accurate&lt;/li&gt;
&lt;li&gt;No render-blocking surprises (fonts/JS)&lt;/li&gt;
&lt;li&gt;Third-party scripts deferred&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>performance</category>
      <category>nextjs</category>
      <category>corewebvitals</category>
    </item>
    <item>
      <title>How I wire Sanity webhooks to Next.js ISR revalidation with HMAC verification</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Wed, 20 May 2026 16:56:15 +0000</pubDate>
      <link>https://dev.to/nayankyada/how-i-wire-sanity-webhooks-to-nextjs-isr-revalidation-with-hmac-verification-1m3a</link>
      <guid>https://dev.to/nayankyada/how-i-wire-sanity-webhooks-to-nextjs-isr-revalidation-with-hmac-verification-1m3a</guid>
      <description>&lt;p&gt;Sanity webhooks and Next.js ISR revalidation are a natural pair, but the wiring has a few sharp edges: verifying the request is genuinely from Sanity, deciding between &lt;code&gt;revalidatePath&lt;/code&gt; and &lt;code&gt;revalidateTag&lt;/code&gt;, and making sure a failed revalidation doesn't silently swallow itself. This post walks through the exact setup I use on production projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why on-demand ISR instead of a fixed interval
&lt;/h2&gt;

&lt;p&gt;Time-based revalidation (e.g. &lt;code&gt;revalidate: 60&lt;/code&gt;) is fine for low-traffic blogs, but it has two problems. First, an editor publishes a fix and waits up to a minute for it to appear — which generates support messages. Second, every page revalidates on a timer whether content changed or not, burning unnecessary compute on Vercel or your own infra. On-demand revalidation via Sanity webhooks flips this: pages stay cached until Sanity tells Next.js something changed, then only the affected paths regenerate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up the Sanity webhook
&lt;/h2&gt;

&lt;p&gt;In Sanity Manage (manage.sanity.io), go to &lt;strong&gt;API → Webhooks → Create&lt;/strong&gt;. Fill in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;URL&lt;/strong&gt;: &lt;code&gt;https://your-site.com/api/revalidate&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trigger on&lt;/strong&gt;: Create, Update, Delete&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter&lt;/strong&gt;: &lt;code&gt;_type == "post" || _type == "page"&lt;/code&gt; (scope it, don't fire on everything)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Projections&lt;/strong&gt;: &lt;code&gt;{_type, slug}&lt;/code&gt; — send only what you need, not the whole document&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secret&lt;/strong&gt;: generate a random string (32+ chars), copy it into your env as &lt;code&gt;SANITY_WEBHOOK_SECRET&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sanity signs each request with an HMAC-SHA256 signature in the &lt;code&gt;sanity-webhook-signature&lt;/code&gt; header. You must verify this before doing anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The route handler
&lt;/h2&gt;

&lt;p&gt;Create the handler at &lt;code&gt;app/api/revalidate/route.ts&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/revalidate/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;revalidatePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;revalidateTag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/headers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timingSafeEqual&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SANITY_WEBHOOK_SECRET&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifySignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signatureHeader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;SECRET&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;signatureHeader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

  &lt;span class="c1"&gt;// Sanity sends: t=&amp;lt;timestamp&amp;gt;,v1=&amp;lt;hex-digest&amp;gt;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;signatureHeader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;v1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headerList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;headerList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sanity-webhook-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifySignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;_type&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bad JSON&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;post&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Tag-based: revalidate just this post and the listing&lt;/span&gt;
      &lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`post:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;page&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;revalidatePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Fallback: nuke the whole site — use sparingly&lt;/span&gt;
      &lt;span class="nf"&gt;revalidatePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;layout&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[revalidate] cache invalidation failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// Return 500 so Sanity retries the webhook&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Revalidation error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;revalidated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth pointing out in this handler. &lt;code&gt;req.text()&lt;/code&gt; comes before any JSON parsing — you need the raw string to recompute the HMAC. Parsing first discards the exact bytes Sanity signed. The &lt;code&gt;timingSafeEqual&lt;/code&gt; call prevents timing attacks that could leak whether the secret is partially correct. And the &lt;code&gt;try/catch&lt;/code&gt; around the revalidation block returns a 500 on failure, which matters for Sanity's retry logic (covered below).&lt;/p&gt;

&lt;h2&gt;
  
  
  revalidatePath vs revalidateTag
&lt;/h2&gt;

&lt;p&gt;Both functions invalidate the Next.js Data Cache and the Full Route Cache, but they target different things.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;revalidatePath(path, type?)&lt;/code&gt; invalidates every &lt;code&gt;fetch&lt;/code&gt; call associated with a specific URL path. Use it when a document maps 1:1 to a route — a page type with a custom slug is the clearest case. Passing &lt;code&gt;'layout'&lt;/code&gt; as the second argument also busts nested layouts, which matters if your nav renders from CMS data.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;revalidateTag(tag)&lt;/code&gt; invalidates every &lt;code&gt;fetch&lt;/code&gt; call that opted into that tag via &lt;code&gt;{ next: { tags: ['post:my-slug'] } }&lt;/code&gt;. Use it for content types that appear on multiple routes — a post shows up at &lt;code&gt;/blog/my-slug&lt;/code&gt; but also on &lt;code&gt;/blog&lt;/code&gt; (the listing), maybe &lt;code&gt;/&lt;/code&gt; (featured posts), and an RSS feed. One &lt;code&gt;revalidateTag('posts')&lt;/code&gt; call busts all of them at once.&lt;/p&gt;

&lt;p&gt;Tag your fetches at the data layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/sanity/queries.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`https://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.api.sanity.io/v2024-01-01/data/query/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?query=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postQuery&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;$slug=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`post:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;revalidate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// rely entirely on on-demand revalidation&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're using the Sanity client rather than raw &lt;code&gt;fetch&lt;/code&gt;, wrap it in a Next.js &lt;code&gt;fetch&lt;/code&gt; call or use &lt;code&gt;unstable_cache&lt;/code&gt; with the same tags. The client's own caching doesn't participate in &lt;code&gt;revalidateTag&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling failed webhooks
&lt;/h2&gt;

&lt;p&gt;Sanity retries a webhook when your endpoint returns a non-2xx status. The default schedule is roughly: immediate, 5 s, 30 s, 5 min, 30 min — five attempts total. This means returning 500 on a revalidation error is the right move; Sanity will try again rather than silently dropping the event.&lt;/p&gt;

&lt;p&gt;Two failure modes to plan for:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Signature mismatch on legitimate requests.&lt;/strong&gt; This usually means the secret in your env doesn't match what Sanity stored. Verify by checking the raw header value in your logs and re-generating the secret in Manage if needed. A 401 response does &lt;em&gt;not&lt;/em&gt; trigger Sanity retries, which is intentional — you don't want an attacker triggering infinite retries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clock skew.&lt;/strong&gt; Sanity embeds a timestamp (&lt;code&gt;t=&lt;/code&gt;) in the signature header. Some implementations reject requests older than 5 minutes. The code above doesn't enforce a time window, but if you want to add it, compare &lt;code&gt;Date.now() / 1000&lt;/code&gt; against &lt;code&gt;parseInt(parts.t)&lt;/code&gt; and return 400 if the delta exceeds 300 seconds.&lt;/p&gt;

&lt;p&gt;For observability, pipe the &lt;code&gt;console.error&lt;/code&gt; in the catch block to whatever you use — Axiom, Better Stack, Sentry. At minimum, log &lt;code&gt;_type&lt;/code&gt;, &lt;code&gt;slug&lt;/code&gt;, and the error message so you can replay manually from the Sanity webhook delivery log if needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local testing
&lt;/h2&gt;

&lt;p&gt;Sanity can't reach localhost, so use a tunnel. With the Vercel CLI: &lt;code&gt;vercel dev&lt;/code&gt; exposes a public URL automatically in some plans. Otherwise, &lt;code&gt;npx localtunnel --port 3000&lt;/code&gt; gives you a temporary URL. Update the webhook URL in Manage, publish a document, and watch the tunnel logs. You can also replay any delivery from the Sanity webhook log under API → Webhooks → recent deliveries — useful when debugging without re-publishing.&lt;/p&gt;

&lt;p&gt;Once this is wired up, your editors get near-instant deploys on publish and you stop paying for pages that regenerate on a timer with no content change behind them.&lt;/p&gt;

</description>
      <category>sanity</category>
      <category>nextjs</category>
      <category>isr</category>
      <category>webhooks</category>
    </item>
    <item>
      <title>How I wire next/image to Sanity hotspot focal point data for pixel-perfect crops</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Wed, 20 May 2026 16:55:32 +0000</pubDate>
      <link>https://dev.to/nayankyada/how-i-wire-nextimage-to-sanity-hotspot-focal-point-data-for-pixel-perfect-crops-lnl</link>
      <guid>https://dev.to/nayankyada/how-i-wire-nextimage-to-sanity-hotspot-focal-point-data-for-pixel-perfect-crops-lnl</guid>
      <description>&lt;p&gt;Sanity's hotspot and crop fields exist for one reason: editors should be able to mark the subject of a photo once, and every downstream size should respect that mark. The problem is that &lt;code&gt;next/image&lt;/code&gt; knows nothing about Sanity's coordinate system out of the box, so you have to wire them together yourself. Here's the exact approach I use on production sites to keep subjects centred across breakpoints without layout shift.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Sanity stores and what next/image needs
&lt;/h2&gt;

&lt;p&gt;When an editor clicks the hotspot tool in Sanity Studio, two objects are written to the document:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;hotspot&lt;/code&gt; — &lt;code&gt;{ x, y, width, height }&lt;/code&gt; where &lt;code&gt;x&lt;/code&gt; and &lt;code&gt;y&lt;/code&gt; are 0–1 fractions of the original image dimensions.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;crop&lt;/code&gt; — &lt;code&gt;{ top, bottom, left, right }&lt;/code&gt; fractions that trim the raw asset before delivery.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;next/image&lt;/code&gt; accepts an &lt;code&gt;object-position&lt;/code&gt; CSS property via its &lt;code&gt;style&lt;/code&gt; prop, but it works in percentages of the rendered container — which maps almost directly to Sanity's &lt;code&gt;hotspot.x&lt;/code&gt; and &lt;code&gt;hotspot.y&lt;/code&gt; values. So the translation is cheap: multiply by 100 and append &lt;code&gt;%&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;@sanity/image-url&lt;/code&gt; builder handles the &lt;code&gt;crop&lt;/code&gt; rectangle server-side when you call &lt;code&gt;.rect()&lt;/code&gt; or let the CDN do it via URL parameters. My preference is to let &lt;code&gt;imageUrlBuilder&lt;/code&gt; apply the crop and pass the resulting URL to &lt;code&gt;next/image&lt;/code&gt;, then use &lt;code&gt;object-position&lt;/code&gt; to keep the hotspot centred in whatever box &lt;code&gt;next/image&lt;/code&gt; renders into.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up the image URL builder
&lt;/h2&gt;

&lt;p&gt;Install the package once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i @sanity/image-url
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then create a small utility module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/sanity-image.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;imageUrlBuilder&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@sanity/image-url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SanityImageSource&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@sanity/image-url/lib/types/types&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./sanity-client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// your configured createClient instance&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;builder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;imageUrlBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;HotspotCrop&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;hotspot&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="nx"&gt;crop&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Returns the CDN URL with crop applied and the CSS object-position
 * string derived from the hotspot coordinates.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getSanityImageProps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SanityImageSource&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;HotspotCrop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;quality&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;hotspot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;crop&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HotspotCrop&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;urlBuilder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;width&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;format&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;crop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Apply the editor's crop rectangle so the CDN trims the raw file.&lt;/span&gt;
    &lt;span class="nx"&gt;urlBuilder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;urlBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;crop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;freeform&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="c1"&gt;// @sanity/image-url rect() wants pixel values; we skip that and&lt;/span&gt;
      &lt;span class="c1"&gt;// rely on the crop object being forwarded automatically when&lt;/span&gt;
      &lt;span class="c1"&gt;// the source contains asset + crop fields.&lt;/span&gt;
      &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="c1"&gt;// placeholder — see note below&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// Simpler: just pass the whole image object; the builder reads&lt;/span&gt;
    &lt;span class="c1"&gt;// _sanityAsset, crop, and hotspot automatically.&lt;/span&gt;
    &lt;span class="nx"&gt;urlBuilder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;builder&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;width&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;format&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;urlBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;objectPosition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;hotspot&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hotspot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;% &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hotspot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;50% 50%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;objectPosition&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One clarification on the builder: when you pass the full Sanity image object (the field value that contains &lt;code&gt;asset&lt;/code&gt;, &lt;code&gt;crop&lt;/code&gt;, and &lt;code&gt;hotspot&lt;/code&gt; keys), &lt;code&gt;imageUrlBuilder&lt;/code&gt; reads the &lt;code&gt;crop&lt;/code&gt; rectangle automatically and applies it to the CDN URL. You do not need to call &lt;code&gt;.rect()&lt;/code&gt; manually. The CDN returns the cropped pixel region, and then &lt;code&gt;object-position&lt;/code&gt; steers the hotspot within whatever CSS container &lt;code&gt;next/image&lt;/code&gt; occupies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using the helper in a Next.js component
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// components/sanity-image.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Image&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getSanityImageProps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;HotspotCrop&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/sanity-image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SanityImageSource&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@sanity/image-url/lib/types/types&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SanityImageSource&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;HotspotCrop&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="nx"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
  &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;SanityImage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;objectPosition&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getSanityImageProps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;relative&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;100%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;aspectRatio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Image&lt;/span&gt;
        &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alt&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;fill&lt;/span&gt;
        &lt;span class="na"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;sizes&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;priority&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;objectFit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cover&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;objectPosition&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few decisions worth explaining:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;fill&lt;/code&gt; instead of explicit width/height.&lt;/strong&gt; The outer &lt;code&gt;div&lt;/code&gt; with a fixed &lt;code&gt;aspect-ratio&lt;/code&gt; reserves the space in the document before the image loads, which is what kills CLS. &lt;code&gt;next/image&lt;/code&gt; in &lt;code&gt;fill&lt;/code&gt; mode injects &lt;code&gt;position: absolute; inset: 0&lt;/code&gt; on the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; element, so it fills the container exactly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;object-position&lt;/code&gt; on the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;.&lt;/strong&gt; This is the hotspot translation. If an editor marks a face at 30% from the left and 20% from the top, &lt;code&gt;object-position: 30% 20%&lt;/code&gt; keeps that point pinned when the browser letterboxes or pillboxes the image inside the container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;sizes&lt;/code&gt; prop.&lt;/strong&gt; Always pass a realistic &lt;code&gt;sizes&lt;/code&gt; string. Something like &lt;code&gt;"(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"&lt;/code&gt; tells Next.js which widths to generate in its srcset. Without it, the browser downloads a much larger file than it needs, which directly hurts LCP.&lt;/p&gt;

&lt;h2&gt;
  
  
  GROQ projection to pull only what you need
&lt;/h2&gt;

&lt;p&gt;Don't fetch the entire image asset. This projection pulls the minimum fields the builder needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// In your page or component query
{
  "image": image {
    asset-&amp;gt;{ _id, url, metadata { dimensions } },
    hotspot,
    crop,
    alt
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;asset-&amp;gt;&lt;/code&gt; dereference fetches &lt;code&gt;dimensions&lt;/code&gt; so you can compute aspect ratios server-side if you want to set &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; on a non-fill image. For the &lt;code&gt;fill&lt;/code&gt; pattern above it's optional, but I always include &lt;code&gt;dimensions&lt;/code&gt; so I can drive the &lt;code&gt;aspect-ratio&lt;/code&gt; CSS from real data rather than hardcoding it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Avoiding the common pitfalls
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Missing &lt;code&gt;overflow: hidden&lt;/code&gt; on the container.&lt;/strong&gt; If you forget it, the image bleeds outside the reserved space and &lt;code&gt;object-position&lt;/code&gt; moves it in a way editors can see but you can't debug easily. Always pair &lt;code&gt;fill&lt;/code&gt; with a relatively-positioned, overflow-hidden parent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Passing &lt;code&gt;hotspot&lt;/code&gt; values outside 0–1.&lt;/strong&gt; Sanity Studio writes normalised fractions, but if you're importing legacy data or using a custom input, validate the range. A hotspot of &lt;code&gt;x: 1.2&lt;/code&gt; will push the image off-screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not forwarding the full image object to the builder.&lt;/strong&gt; If you pass only &lt;code&gt;asset._ref&lt;/code&gt; as the source, the builder ignores &lt;code&gt;crop&lt;/code&gt; and &lt;code&gt;hotspot&lt;/code&gt;. Pass the whole field value — the object with &lt;code&gt;asset&lt;/code&gt;, &lt;code&gt;crop&lt;/code&gt;, and &lt;code&gt;hotspot&lt;/code&gt; keys — and the builder wires them up automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Over-generating srcset widths.&lt;/strong&gt; The default Next.js &lt;code&gt;deviceSizes&lt;/code&gt; config is broad. For a blog that maxes out at 800 px wide, trim the config in &lt;code&gt;next.config.ts&lt;/code&gt; to avoid serving unnecessarily large files to the Sanity CDN.&lt;/p&gt;

&lt;p&gt;Once the wiring is in place, editors can move the hotspot handle freely in Studio and every breakpoint crop adjusts without a code change — which is the actual promise of the hotspot feature.&lt;/p&gt;

</description>
      <category>nextimage</category>
      <category>sanitycms</category>
      <category>hotspot</category>
      <category>imageoptimisation</category>
    </item>
    <item>
      <title>How I use Sanity's structure builder to hide draft noise and speed up editor workflow</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Wed, 20 May 2026 16:55:31 +0000</pubDate>
      <link>https://dev.to/nayankyada/how-i-use-sanitys-structure-builder-to-hide-draft-noise-and-speed-up-editor-workflow-5624</link>
      <guid>https://dev.to/nayankyada/how-i-use-sanitys-structure-builder-to-hide-draft-noise-and-speed-up-editor-workflow-5624</guid>
      <description>&lt;h2&gt;
  
  
  The problem with default Sanity Studio structure
&lt;/h2&gt;

&lt;p&gt;Sanity's default desk structure shows every document and every draft in a flat list. On a production site with 400+ pages, 60 blog posts, and 12 active editors, that list becomes unmanageable. Editors see &lt;code&gt;drafts.blog-post-title&lt;/code&gt; and &lt;code&gt;blog-post-title&lt;/code&gt; side by side. They ask: "Which one is published? Why are there two?" I've had clients accidentally publish the wrong version because they clicked the wrong item in a crowded list.&lt;/p&gt;

&lt;p&gt;The default structure also surfaces internal documents — redirects, global settings, navigation singletons — that non-technical editors should never touch. Every support ticket I avoided started with a custom structure builder config.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I use structure builder to partition documents
&lt;/h2&gt;

&lt;p&gt;I create a &lt;code&gt;src/sanity/structure.ts&lt;/code&gt; file and export a custom &lt;code&gt;structure&lt;/code&gt; function. I import it in &lt;code&gt;sanity.config.ts&lt;/code&gt; like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/sanity/structure.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;StructureBuilder&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sanity/structure&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;structure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;StructureBuilder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
      &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listItem&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;documentTypeList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;page&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_type == "page" &amp;amp;&amp;amp; !(_id in path("drafts.**"))&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defaultOrdering&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;asc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}])&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listItem&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Blog Posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;documentTypeList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;post&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Blog Posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_type == "post" &amp;amp;&amp;amp; !(_id in path("drafts.**"))&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defaultOrdering&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;publishedAt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;desc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}])&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;divider&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listItem&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Site Settings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;document&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schemaType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;siteSettings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;siteSettings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listItem&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;document&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schemaType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key line is &lt;code&gt;.filter('_type == "page" &amp;amp;&amp;amp; !(_id in path("drafts.**"))')&lt;/code&gt;. This GROQ filter hides all drafts from the list. Editors only see published documents. When they click a document, Sanity automatically loads the draft if one exists. This cuts visual noise by 50% immediately.&lt;/p&gt;

&lt;p&gt;I also surface singletons — &lt;code&gt;siteSettings&lt;/code&gt;, &lt;code&gt;navigation&lt;/code&gt; — as direct list items instead of making editors hunt through a "Settings" document type list. One click to the doc they need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grouping by status with custom list panes
&lt;/h2&gt;

&lt;p&gt;On a blog with 60 posts, I want editors to see "Published" and "Drafts" as separate lists. I create a child structure with two filtered lists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listItem&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Blog Posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Blog Posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listItem&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Published&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;documentTypeList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;post&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Published Posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_type == "post" &amp;amp;&amp;amp; !(_id in path("drafts.**")) &amp;amp;&amp;amp; defined(publishedAt)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defaultOrdering&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;publishedAt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;desc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}])&lt;/span&gt;
          &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listItem&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Drafts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;documentTypeList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;post&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Draft Posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_type == "post" &amp;amp;&amp;amp; _id in path("drafts.**")&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defaultOrdering&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_updatedAt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;desc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}])&lt;/span&gt;
          &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listItem&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Scheduled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;documentTypeList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;post&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Scheduled Posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_type == "post" &amp;amp;&amp;amp; !(_id in path("drafts.**")) &amp;amp;&amp;amp; publishedAt &amp;gt; now()&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defaultOrdering&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;publishedAt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;asc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}])&lt;/span&gt;
          &lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now editors see three buckets: Published, Drafts, Scheduled. The &lt;code&gt;publishedAt &amp;gt; now()&lt;/code&gt; filter requires a &lt;code&gt;publishedAt&lt;/code&gt; datetime field in the schema. I use this pattern on every blog I build. It reduces "Where's my post?" Slack messages to zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hiding internal document types entirely
&lt;/h2&gt;

&lt;p&gt;I have document types like &lt;code&gt;redirect&lt;/code&gt;, &lt;code&gt;analyticsEvent&lt;/code&gt;, &lt;code&gt;structuredData&lt;/code&gt; that only I touch. I don't want them in the desk at all. I filter them out in the structure builder and add them to a hidden "Internal" list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="c1"&gt;// ... public lists&lt;/span&gt;
    &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;divider&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listItem&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Internal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Internal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;documentTypeListItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;redirect&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Redirects&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;documentTypeListItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;structuredData&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Structured Data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="p"&gt;])&lt;/span&gt;
      &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I collapse "Internal" by default. Editors never see it unless they explicitly expand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custom ordering with &lt;code&gt;defaultOrdering&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Sanity's default ordering is &lt;code&gt;_createdAt desc&lt;/code&gt;. For pages, I want alphabetical by title. For blog posts, I want newest published first. For FAQs, I want a manual &lt;code&gt;order&lt;/code&gt; field. I set these per list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;documentTypeList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;faq&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FAQs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_type == "faq" &amp;amp;&amp;amp; !(_id in path("drafts.**"))&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defaultOrdering&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;order&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;asc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This requires an &lt;code&gt;order&lt;/code&gt; number field in the &lt;code&gt;faq&lt;/code&gt; schema. I use &lt;code&gt;validation: Rule =&amp;gt; Rule.required().min(0)&lt;/code&gt; to enforce it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters for large sites
&lt;/h2&gt;

&lt;p&gt;On a 400-page corporate site I shipped last year, the client had 8 content editors across 3 departments. Before I added structure builder config, they complained the desk was "too cluttered" and "confusing". After I grouped pages by section ("Products", "Resources", "Legal"), hid drafts, and surfaced settings as top-level items, onboarding time dropped from 45 minutes to 15 minutes. Support tickets about "I can't find my page" went to zero.&lt;/p&gt;

&lt;p&gt;The structure builder config is 150 lines of TypeScript. It's the highest ROI config file in a Sanity project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats and edge cases
&lt;/h2&gt;

&lt;p&gt;If you use document internationalization (&lt;code&gt;@sanity/document-internationalization&lt;/code&gt;), drafts behave differently. Each locale creates its own draft. I handle this by adding &lt;code&gt;&amp;amp;&amp;amp; !defined(__i18n_base)&lt;/code&gt; to my filters to hide translation metadata docs.&lt;/p&gt;

&lt;p&gt;If you have a document type with thousands of items (product SKUs, for instance), consider adding a search-based list item instead of a flat list. Sanity's default search is fast, but a list of 5,000 items will timeout in the browser.&lt;/p&gt;

&lt;p&gt;Structure builder functions are synchronous. You can't fetch data from an external API to build dynamic lists. If you need that, use an action or a custom input component instead.&lt;/p&gt;

</description>
      <category>sanitycms</category>
      <category>studioconfiguration</category>
      <category>devrel</category>
      <category>contentops</category>
    </item>
  </channel>
</rss>
