<?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.us-east-2.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>CMS apps explained: what marketing and product teams actually get</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Tue, 21 Jul 2026 07:04:56 +0000</pubDate>
      <link>https://dev.to/nayankyada/cms-apps-explained-what-marketing-and-product-teams-actually-get-44mp</link>
      <guid>https://dev.to/nayankyada/cms-apps-explained-what-marketing-and-product-teams-actually-get-44mp</guid>
      <description>&lt;p&gt;If you're researching CMS apps for your team and finding yourself buried in feature matrices and pricing pages, this is the post to read first. Before you evaluate any specific tool, it helps to understand what a modern CMS actually does — and where the real differences between products show up in daily editing work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CMS apps actually do
&lt;/h2&gt;

&lt;p&gt;A content management system is software that lets non-developers create, edit, and publish content without touching code. That's the old definition. Modern CMS apps have expanded well beyond that: they now manage content &lt;em&gt;structure&lt;/em&gt;, enforce editorial workflows, deliver content to multiple channels simultaneously, and connect to the rest of your marketing stack.&lt;/p&gt;

&lt;p&gt;The shift matters because the original generation of CMS tools — WordPress, Drupal, older Joomla installations — bundled the editing interface, the content database, and the website display layer into one product. That worked fine when a website was the only destination for your content. It breaks down the moment you want the same product description to appear on a website, a mobile app, a digital kiosk, and an email campaign without copying and pasting between systems.&lt;/p&gt;

&lt;p&gt;Modern CMS apps split those concerns apart, or at least give you more control over how they connect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Content models: the layer most buyers overlook
&lt;/h2&gt;

&lt;p&gt;Before your editors ever type a word, someone has to define what kinds of content exist in the system and what fields each type has. This is called the content model, and it's the most consequential architectural decision in any CMS project.&lt;/p&gt;

&lt;p&gt;A content model for a software company might include document types like Blog Post, Case Study, Product Page, Team Member, and Pricing Tier. Each type has fields: a Blog Post has a title, a publication date, a main image, an author reference, and a body. A Pricing Tier has a name, a monthly price, a list of features, and a boolean for whether it's the recommended option.&lt;/p&gt;

&lt;p&gt;Why does this matter to you as a buyer? Because a poorly designed content model creates editorial friction that compounds over years. If your Case Studies are modeled as a long unstructured text blob rather than discrete fields (client name, industry, outcome metric, quote), your team will fight the CMS every time they try to filter, sort, or reuse that content elsewhere. A good CMS app gives developers the tools to build a tight content model; a good developer actually uses them.&lt;/p&gt;

&lt;p&gt;Different CMS apps approach content modeling differently. Some give you a fixed set of field types and a visual schema builder. Others — Sanity is the clearest example here — define schemas entirely in code, which means the model lives in version control, can be reviewed like any other change, and can be tested before deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The editing interface
&lt;/h2&gt;

&lt;p&gt;This is what most buyers focus on, and reasonably so — your marketing team will live in it every day. Modern CMS apps generally offer:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured fields.&lt;/strong&gt; Dropdowns, date pickers, image uploaders, reference selectors. Editors fill in discrete fields rather than formatting everything inside a single rich-text box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rich text with portable structure.&lt;/strong&gt; When you do need long-form text, good CMS apps represent it as structured data rather than raw HTML. This means you can render the same content differently on a website versus a mobile app, and custom components — a callout block, an embedded video, a product card — can be inserted inline without embedding fragile markup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Visual previews.&lt;/strong&gt; Most modern CMS apps support some form of live preview, where editors can see what a page will look like before publishing. The implementation quality varies significantly: some are full visual editing with click-to-edit overlays; others are just an iframe that refreshes when you save a draft.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Asset management.&lt;/strong&gt; Images, documents, and video are usually managed inside the CMS or via a connected digital asset management tool. Decent CMS apps handle focal-point cropping (so an image looks right when displayed at multiple aspect ratios) and serve optimised formats automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publishing workflows
&lt;/h2&gt;

&lt;p&gt;A basic CMS has two states: draft and published. Most teams outgrow that within months.&lt;/p&gt;

&lt;p&gt;Marketing teams typically need: draft → internal review → legal or brand approval → scheduled publish → live. Product teams doing feature launches often need content staged and ready to go live the moment a deploy ships.&lt;/p&gt;

&lt;p&gt;Look for CMS apps that support:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Role-based permissions.&lt;/strong&gt; Editors create; senior editors approve; admins publish. A junior contractor should not be able to push to production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled publishing.&lt;/strong&gt; Set a date and time; the CMS handles the rest. Non-negotiable for any team running campaigns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content versioning.&lt;/strong&gt; The ability to see what a document looked like three weeks ago and restore it if needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Draft preview URLs.&lt;/strong&gt; Shareable links that show a draft to a stakeholder without publishing it publicly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not every CMS app does all of these well. Some charge extra for workflow features. Some implement them only at the enterprise tier. It's worth pressure-testing these specifically during any evaluation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integrations with the rest of your stack
&lt;/h2&gt;

&lt;p&gt;CMS apps don't live in isolation. The relevant integration categories for most marketing and product teams:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Search.&lt;/strong&gt; If your site has more than a few dozen pages, you'll want full-text search. Most headless CMS apps integrate with Algolia or a similar index service, syncing content automatically when you publish.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Analytics and personalisation.&lt;/strong&gt; Some CMS apps have first-party analytics add-ons; most rely on you connecting Google Analytics, Segment, or similar via your frontend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;E-commerce.&lt;/strong&gt; If product content needs to stay in sync with a Shopify or similar catalogue, check whether the CMS has a native connector or whether that sync is a custom build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Email and marketing automation.&lt;/strong&gt; Content reuse across CMS and email campaigns is often messier than vendors suggest. The honest answer is: most teams still copy and paste, unless they invest in a deliberate integration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deployment triggers.&lt;/strong&gt; Headless CMS apps typically fire a webhook when content is published, which triggers a rebuild or cache invalidation on the website. This is nearly invisible when it works and very confusing when it doesn't — worth asking any developer you're evaluating how they handle and monitor it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing the right class of CMS app
&lt;/h2&gt;

&lt;p&gt;The broad categories you'll encounter:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;Watch out for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Traditional (WordPress, Drupal)&lt;/td&gt;
&lt;td&gt;Simple sites, tight budgets, familiar editors&lt;/td&gt;
&lt;td&gt;Coupling to one frontend, plugin maintenance debt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosted headless (Sanity, Contentful, Storyblok)&lt;/td&gt;
&lt;td&gt;Multi-channel, design-system-driven sites&lt;/td&gt;
&lt;td&gt;Per-seat costs, API call limits at scale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted headless (Payload, Strapi)&lt;/td&gt;
&lt;td&gt;Teams that want full data control&lt;/td&gt;
&lt;td&gt;Infrastructure and security burden on your team&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Visual / page-builder (Webflow, Builder.io)&lt;/td&gt;
&lt;td&gt;Marketing teams who need layout control&lt;/td&gt;
&lt;td&gt;Can create brittle designs outside a design system&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The right answer depends on your team's technical capacity, how many channels you publish to, and how much editorial flexibility you actually need versus how much you think you need. Most marketing sites do not require the most sophisticated option. Most fast-growing product companies hit the limits of the simplest option faster than they expect.&lt;/p&gt;

&lt;p&gt;When you're ready to evaluate specific tools or talk to a developer about implementation, the content model conversation is the one worth having first.&lt;/p&gt;

</description>
      <category>headlesscms</category>
      <category>sanitycms</category>
      <category>sanitystudio</category>
      <category>seo</category>
    </item>
    <item>
      <title>Tailwind CSS in 2026: what actually changed for teams</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Mon, 20 Jul 2026 07:40:09 +0000</pubDate>
      <link>https://dev.to/nayankyada/tailwind-css-in-2026-what-actually-changed-for-teams-21d5</link>
      <guid>https://dev.to/nayankyada/tailwind-css-in-2026-what-actually-changed-for-teams-21d5</guid>
      <description>&lt;p&gt;Tailwind CSS in 2026 is a fundamentally different tool from the one most teams learned in 2022. The v4 engine, CSS-first configuration, and first-class container queries have each individually reshaped daily workflow — and the combination is worth talking about plainly, separate from any migration checklist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CSS-first config is not just syntax sugar
&lt;/h2&gt;

&lt;p&gt;The headline change in v4 is that &lt;code&gt;tailwind.config.js&lt;/code&gt; is gone by default. Design tokens live in a &lt;code&gt;@theme&lt;/code&gt; block inside your CSS file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* app/globals.css */&lt;/span&gt;
&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s1"&gt;"tailwindcss"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#1d4ed8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--font-display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"Inter"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--spacing-18&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4.5rem&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;This feels like a small ergonomic shift until you actually live with it on a team. The old &lt;code&gt;tailwind.config.js&lt;/code&gt; was a JavaScript file, which means it got imported, extended, and occasionally mutated by plugins in ways that were hard to audit. Designers who opened the repo could not read it comfortably. New engineers would ask whether the config was the source of truth or just one layer on top of a preset.&lt;/p&gt;

&lt;p&gt;A CSS file with &lt;code&gt;@theme&lt;/code&gt; is unambiguous. It co-locates with the rest of your styles. It gets reviewed in the same diff as component changes. A junior dev on a team I worked with last quarter noticed a color token drift between Figma and production within two days of this setup, something that had been invisible for months under the old config structure.&lt;/p&gt;

&lt;p&gt;What teams actually need to watch for: any tooling that statically parses &lt;code&gt;tailwind.config.js&lt;/code&gt; — design token exporters, Storybook addons, custom CLI scripts — will break silently. Audit those before the switch, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Oxide engine changes what the build step costs
&lt;/h2&gt;

&lt;p&gt;V4's Rust-based engine (Oxide) isn't a minor performance improvement. On the largest Next.js project I maintain — roughly 380 components, a design system package, and a monorepo with four apps — full CSS rebuilds dropped from around 800ms to under 80ms. Incremental rebuilds became effectively instant.&lt;/p&gt;

&lt;p&gt;For most teams this doesn't change what ships to users, since CSS is built once at deploy time. But it changes the development feedback loop in a real way. Hot reload with Tailwind classes no longer has a perceptible lag. That matters more than it sounds: slow Tailwind rebuilds used to push developers toward inline styles or &lt;code&gt;style&lt;/code&gt; attributes to avoid the wait, which undermined the whole point.&lt;/p&gt;

&lt;p&gt;Turbopack + Oxide is the pairing worth actually running in 2026. Next.js with &lt;code&gt;next dev --turbopack&lt;/code&gt; and v4 Tailwind gives you a dev loop that is competitive with Vite on startup and incremental speed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Container queries are now a first-class workflow, not a plugin
&lt;/h2&gt;

&lt;p&gt;V3 had container queries behind &lt;code&gt;@tailwindcss/container-queries&lt;/code&gt;. V4 ships them in core:&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;// components/card.tsx&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;Card&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&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="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="s"&gt;"@container"&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="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"grid grid-cols-1 @md:grid-cols-2 @lg:grid-cols-3 gap-4"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&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;&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;This changes how component libraries get built. The old pattern was: build a component, expose &lt;code&gt;className&lt;/code&gt; props, let the consumer manage layout breakpoints. The new pattern is: build a component that is genuinely self-contained — it knows how to render in a narrow sidebar and a full-width hero without the consumer passing responsive props.&lt;/p&gt;

&lt;p&gt;For teams that sell or share component libraries internally, this is a meaningful workflow shift. Components can finally be tested in isolation without needing to fake viewport widths. Storybook stories become more trustworthy because the component reacts to its container, not to the story viewport.&lt;/p&gt;

&lt;p&gt;The practical catch: container queries require a named containment context to work correctly in certain nested layouts. &lt;code&gt;@container&lt;/code&gt; without a name defaults to the nearest ancestor with containment, which creates subtle bugs when components are composed. Naming containers explicitly (&lt;code&gt;@container/sidebar&lt;/code&gt;) is worth making a team convention from day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for the design-to-code handoff
&lt;/h2&gt;

&lt;p&gt;The part most opinion posts skip: v4 actually moves Tailwind closer to being a shared language between design and engineering, not just a developer utility.&lt;/p&gt;

&lt;p&gt;When your design tokens live in a CSS file rather than a JS object, they can be generated directly from Figma variable exports via a simple script. Several teams I know have wired up a GitHub Action that exports Figma tokens as CSS variables and opens a PR against &lt;code&gt;globals.css&lt;/code&gt;. That was theoretically possible with v3 but required a transform step through JavaScript. Now it is a direct write.&lt;/p&gt;

&lt;p&gt;The flipside is that CSS-in-JS tooling that reads Tailwind config to build theme context — Stitches-style setups, some Chakra migrations — needs rethinking. If your team has that kind of layering, v4 is a bigger architectural change than the migration guide implies.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest team verdict
&lt;/h2&gt;

&lt;p&gt;Tailwind CSS in 2026 is genuinely better for teams that were already using it. The config clarity, build speed, and container query ergonomics are real improvements that reduce friction in practice. It is not meaningfully easier to adopt from scratch than v3 was — the learning curve for the utility-class model is the same.&lt;/p&gt;

&lt;p&gt;If your team is on v3 and things are working, the upgrade is worth doing for the build performance alone. If you were already stretching the plugin ecosystem to handle container queries and dynamic theming, v4 removes the scaffolding you were building around the gaps.&lt;/p&gt;

&lt;p&gt;The one thing I'd push back on is the idea that CSS-first config is simpler for everyone. If your design system lives in a monorepo and multiple apps need to consume the same tokens, the CSS import model requires discipline around layer ordering and import paths that a shared JS config handled more automatically. Solvable — but you need to design the token-sharing architecture before you migrate, not during.&lt;/p&gt;

</description>
      <category>performance</category>
      <category>bundlesize</category>
      <category>react</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Sanity video hosting in 2026: Mux vs Cloudinary vs Bunny Stream</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Sat, 18 Jul 2026 06:27:38 +0000</pubDate>
      <link>https://dev.to/nayankyada/sanity-video-hosting-in-2026-mux-vs-cloudinary-vs-bunny-stream-5bh</link>
      <guid>https://dev.to/nayankyada/sanity-video-hosting-in-2026-mux-vs-cloudinary-vs-bunny-stream-5bh</guid>
      <description>&lt;p&gt;Choosing sanity video hosting is not the same decision as choosing Sanity CMS itself. Sanity stores your video asset reference; a separate vendor actually transcodes, stores, and streams the file. In 2026 three vendors come up repeatedly when teams evaluate this: Mux, Cloudinary, and Bunny Stream. They differ sharply on upload UX inside Sanity Studio, adaptive streaming support, how they handle poster images (which directly affects LCP), and — most visibly — cost once you scale past a demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the comparison covers
&lt;/h2&gt;

&lt;p&gt;Each vendor is evaluated on four axes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Studio upload workflow&lt;/strong&gt; — does a first-party or community plugin exist, or do you wire a custom input?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adaptive streaming&lt;/strong&gt; — HLS/MPEG-DASH with per-resolution renditions, or a single progressive download?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Poster and thumbnail handling&lt;/strong&gt; — server-generated frame URLs you can feed to &lt;code&gt;next/image&lt;/code&gt; for LCP optimisation?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pricing structure&lt;/strong&gt; — what the free/starter tier covers and what triggers the first meaningful bill.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I am not going to recommend one unconditionally. The right pick depends on whether you need signed playback, how much you care about Studio editor UX, and whether your traffic is predictable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mux
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Upload workflow.&lt;/strong&gt; Mux has the most mature Sanity integration. The &lt;code&gt;sanity-plugin-mux-input&lt;/code&gt; package (maintained, currently on v2) adds a drop-in custom input that uploads directly to Mux's upload URL API without routing files through your own server. Editors get a progress bar and a playback preview inside Studio. Schema wiring is around ten lines.&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;// schemas/video.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;defineField&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="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;videoPost&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;video&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;mux.video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// registered by sanity-plugin-mux-input&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;Video&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;The plugin stores a &lt;code&gt;playbackId&lt;/code&gt; and &lt;code&gt;assetId&lt;/code&gt; on the document. You query &lt;code&gt;playbackId&lt;/code&gt; via GROQ and construct the HLS URL client-side: &lt;code&gt;https://stream.mux.com/{playbackId}.m3u8&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adaptive streaming.&lt;/strong&gt; Full HLS with automatic per-title encoding. Mux generates renditions at multiple bitrates per asset; the player selects the right one per viewer. This is the most production-ready adaptive streaming option of the three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Poster handling.&lt;/strong&gt; Mux generates thumbnail URLs from any frame: &lt;code&gt;https://image.mux.com/{playbackId}/thumbnail.webp?time=3&lt;/code&gt;. That URL is a real image you can pass directly to &lt;code&gt;next/image&lt;/code&gt; with &lt;code&gt;priority&lt;/code&gt; to hit LCP. Width and height are predictable from your Mux asset metadata.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing.&lt;/strong&gt; Mux bills on storage ($0.015/GB/month) and delivery ($0.009/min streamed). There is no meaningful free tier for production — the free tier gives you 10 GB storage and 100 minutes of delivery per month, which is a test environment, not a live site. A mid-traffic site streaming 2 000 minutes/month and storing 20 GB hits roughly $25–35/month before any per-seat Sanity cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cloudinary
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Upload workflow.&lt;/strong&gt; Cloudinary has a &lt;code&gt;sanity-plugin-cloudinary&lt;/code&gt; package but it sees less active maintenance than the Mux plugin. The pattern stores a Cloudinary &lt;code&gt;public_id&lt;/code&gt; on the document. Alternatively you can build a custom input with Cloudinary's upload widget (JavaScript, loaded client-side in the Studio), but that means more glue code. Editors do get an in-Studio upload experience if you wire it correctly; it just requires more setup than Mux.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adaptive streaming.&lt;/strong&gt; Cloudinary supports HLS adaptive streaming but it is not on by default. You must request it via transformation parameters (&lt;code&gt;sp_hd/&lt;/code&gt; prefix on the URL or via a named transformation). Out of the box you get a progressive MP4 delivery, which works but does not adapt to bandwidth. For most video-light sites (hero clips, explainers under 3 minutes) this is fine. For long-form it is a real limitation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Poster handling.&lt;/strong&gt; Cloudinary's transformation API is strong here. A poster URL looks like:&lt;br&gt;
&lt;code&gt;https://res.cloudinary.com/{cloud}/video/upload/so_3,f_webp,q_auto,w_1280/{public_id}.webp&lt;/code&gt;&lt;br&gt;
You get frame extraction at any second (&lt;code&gt;so_&lt;/code&gt; parameter), automatic format negotiation, and quality tuning — all without a separate image CDN. That flexibility is genuinely useful if you also serve images through Cloudinary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing.&lt;/strong&gt; Cloudinary uses a credit system. The free tier gives 25 credits/month (roughly 25 GB storage or transformation-weighted equivalent). Video transformations consume credits faster than image transformations. A realistic video-heavy site — 10 videos averaging 200 MB, regular re-transcoding for new formats — can exhaust the free tier quickly. The Plus plan at $89/month is the first meaningful paid tier. If you are already paying for Cloudinary for images, adding video has marginal incremental cost until you exceed your credit ceiling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bunny Stream
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Upload workflow.&lt;/strong&gt; There is no official Sanity plugin for Bunny Stream as of mid-2026. You write a custom input or a Sanity action that calls Bunny's REST API to create a video object, retrieve an upload URL, and store the Bunny video GUID on the document. It is maybe 60–80 lines of TypeScript and not difficult, but it is not a packaged install. Editors upload from Studio but the integration is yours to maintain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adaptive streaming.&lt;/strong&gt; Bunny Stream transcodes to HLS with multiple renditions automatically on upload — 240p through 1080p (4K on higher plans). It matches Mux's adaptive streaming quality and is the main reason Bunny is worth considering at all despite the missing plugin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Poster handling.&lt;/strong&gt; Bunny generates a thumbnail at &lt;code&gt;https://vz-{zone}.b-cdn.net/{guid}/{frame}.jpg&lt;/code&gt;. Frame extraction is available but less flexible than Cloudinary — you select from pre-generated thumbnails or upload a custom one via the API. For LCP purposes it works: you get a stable URL to pass to &lt;code&gt;next/image&lt;/code&gt;. You will want to set explicit &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; to avoid CLS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing.&lt;/strong&gt; Bunny is substantially cheaper than Mux or Cloudinary for delivery-heavy workloads. Storage costs $0.005–0.01/GB/month depending on region. Delivery (bandwidth) costs $0.005–0.015/GB depending on zone, billed per GB served rather than per minute. For a site streaming 100 GB/month, Bunny runs $0.50–1.50 in delivery vs Mux at roughly $18–22 for the same load (converting minutes to GB at typical bitrates). The trade-off is you are owning more integration code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side-by-side summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Mux&lt;/th&gt;
&lt;th&gt;Cloudinary&lt;/th&gt;
&lt;th&gt;Bunny Stream&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sanity Studio plugin&lt;/td&gt;
&lt;td&gt;Official, maintained&lt;/td&gt;
&lt;td&gt;Community, less active&lt;/td&gt;
&lt;td&gt;None — custom input required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adaptive HLS&lt;/td&gt;
&lt;td&gt;Yes, automatic&lt;/td&gt;
&lt;td&gt;Optional (transformation param)&lt;/td&gt;
&lt;td&gt;Yes, automatic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Poster/thumbnail URL&lt;/td&gt;
&lt;td&gt;Yes (&lt;code&gt;image.mux.com&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Yes (transformation API)&lt;/td&gt;
&lt;td&gt;Yes (pre-generated frames)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free tier&lt;/td&gt;
&lt;td&gt;10 GB / 100 min/month&lt;/td&gt;
&lt;td&gt;25 credits/month&lt;/td&gt;
&lt;td&gt;1 TB bandwidth trial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Indicative cost at modest scale&lt;/td&gt;
&lt;td&gt;$25–40/month&lt;/td&gt;
&lt;td&gt;$0–89/month (credit-dependent)&lt;/td&gt;
&lt;td&gt;$2–8/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LCP-friendly poster&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (best transformation control)&lt;/td&gt;
&lt;td&gt;Yes (limited frame selection)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signed/private playback&lt;/td&gt;
&lt;td&gt;Yes (signed tokens)&lt;/td&gt;
&lt;td&gt;Yes (signed URLs)&lt;/td&gt;
&lt;td&gt;Yes (token auth)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Which to pick
&lt;/h2&gt;

&lt;p&gt;If editor experience in Studio is a priority and your client or team expects a polished upload UI without custom code, &lt;strong&gt;Mux&lt;/strong&gt; is the least friction option. The plugin is solid, the HLS delivery is reliable, and the poster URL is a first-class feature. The cost is higher but predictable.&lt;/p&gt;

&lt;p&gt;If you are already running Cloudinary for image delivery and your video library is small (under 20 videos, short clips), &lt;strong&gt;Cloudinary&lt;/strong&gt; avoids adding another vendor. The credit model gets expensive fast on large video catalogues, and adaptive streaming needs deliberate setup.&lt;/p&gt;

&lt;p&gt;If delivery cost is the deciding constraint — you have high bandwidth, predictable load, and a developer who can write 80 lines of custom integration — &lt;strong&gt;Bunny Stream&lt;/strong&gt; is hard to beat on price. Adaptive HLS works well, thumbnails are usable, and the bandwidth cost at scale is genuinely lower than Mux by a factor of 10 or more on some workloads.&lt;/p&gt;

&lt;p&gt;None of these vendors is wrong. The Mux-specific tutorials elsewhere on this site (signed HLS, upload and playback) remain valid if you pick Mux. But picking Mux as a default without evaluating Bunny's cost curve is a decision worth making explicitly, especially on projects where video volume is high and editor-facing upload UX is a secondary concern.&lt;/p&gt;

</description>
      <category>sanitycms</category>
      <category>mux</category>
      <category>performance</category>
      <category>comparison</category>
    </item>
    <item>
      <title>Sanity vs WordPress cost: annual TCO once you're already live</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Fri, 17 Jul 2026 06:35:01 +0000</pubDate>
      <link>https://dev.to/nayankyada/sanity-vs-wordpress-cost-annual-tco-once-youre-already-live-4elo</link>
      <guid>https://dev.to/nayankyada/sanity-vs-wordpress-cost-annual-tco-once-youre-already-live-4elo</guid>
      <description>&lt;p&gt;The migration debate is settled elsewhere. This post is about what you actually spend every year once the site is built and handed over — Sanity + Next.js on one side, a self-hosted WordPress on the other. Both stacks running in production, both with real editors, both needing to stay secure and fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  What counts as steady-state cost
&lt;/h2&gt;

&lt;p&gt;I'm counting four buckets: hosting infrastructure, third-party licenses (plugins, themes, SaaS add-ons), the developer time consumed by maintenance and security patching, and the hidden cost of editor inefficiency — the hours content teams lose to a slow, brittle CMS UI. I'm not counting one-time migration work or design fees; the switch-over economics are covered separately in &lt;a href="https://nayankyada.com/blog/wordpress-to-sanity-migration-cost-does-it-actually-pay-back" rel="noopener noreferrer"&gt;WordPress to Sanity migration cost: does it pay back?&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All figures below are 2026 market rates, USD, for a mid-size content site: 20–50k monthly sessions, one Sanity project or one WordPress install, two to five editors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hosting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WordPress&lt;/strong&gt; almost always runs on a managed WordPress host. WP Engine Startup is $30/month ($360/year). If you grow past 25k visits/month or need staging + production parity, you're on Growth at $77/month ($924/year). Add Cloudflare Pro ($240/year) if you want proper CDN and DDoS mitigation without relying on the host's bundled CDN, which is rate-limited at the cheaper tiers. Realistic annual hosting: &lt;strong&gt;$600–$1,200&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sanity + Next.js&lt;/strong&gt; splits into two bills. Sanity's Growth plan is $15/month per project ($180/year) — that covers 100k API CDN requests per month, 20 GB bandwidth, three non-admin users, and 10k documents. Most small-to-mid content sites fit here indefinitely; the free tier covers solo developers. Next.js on Vercel Pro is $20/month per seat for the first developer, then $20/month per additional member; for a two-developer team that's $480/year. Alternatively, a $20/month Fly.io VM or a $24/month Render instance covers the Next.js runtime if you prefer not to be on Vercel. Realistic annual hosting: &lt;strong&gt;$660–$840&lt;/strong&gt; (Vercel Pro + Sanity Growth, two devs). You can go lower on Vercel Hobby for personal projects, but production sites with preview URLs and team access need Pro.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plugin and license costs
&lt;/h2&gt;

&lt;p&gt;This is where WordPress stacks up fast. A production WordPress site in 2026 typically carries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Advanced Custom Fields Pro: $49/year&lt;/li&gt;
&lt;li&gt;WP Rocket or similar caching plugin: $69/year&lt;/li&gt;
&lt;li&gt;Yoast SEO Premium or Rank Math Pro: $99–$129/year&lt;/li&gt;
&lt;li&gt;Gravity Forms or WPForms Pro: $99–$259/year&lt;/li&gt;
&lt;li&gt;A premium theme or Elementor Pro: $59–$199/year&lt;/li&gt;
&lt;li&gt;WP Offload Media (S3/CloudFront): $99/year&lt;/li&gt;
&lt;li&gt;Wordfence Premium or Sucuri: $119–$299/year&lt;/li&gt;
&lt;li&gt;BackupBuddy or similar: $99/year&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Conservative total for a real production site: &lt;strong&gt;$800–$1,400/year&lt;/strong&gt; in plugin licenses alone. I've audited WordPress installs for clients that topped $2,000/year in renewals across 15+ plugins, several of which were redundant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sanity + Next.js&lt;/strong&gt; has near-zero third-party license spend at this scale. next/image handles image optimisation natively. Sanity's built-in SEO fields replace Yoast. Form handling goes through a route handler to SendGrid ($19.95/month on Essentials if volume is low, often free for simple contact forms). There's no caching plugin — Next.js ISR and Vercel's edge network handle that. No security plugin — the attack surface doesn't exist. Realistic annual licenses: &lt;strong&gt;$0–$240&lt;/strong&gt; (SendGrid or similar, optional).&lt;/p&gt;

&lt;h2&gt;
  
  
  Maintenance and security-patching time
&lt;/h2&gt;

&lt;p&gt;This is the cost most spreadsheets miss. WordPress core releases major versions roughly three times per year; plugins release updates constantly. A disciplined team tests updates in staging before pushing to production. That cycle — pull update, smoke-test, push, verify — runs about 1.5–2 hours per month for a site with 10–20 active plugins. At a conservative $80/hour developer rate, that's &lt;strong&gt;$1,440–$1,920/year&lt;/strong&gt; in pure maintenance time.&lt;/p&gt;

&lt;p&gt;WordPress is also the most-targeted CMS on the internet. Plugin vulnerabilities (ACF, WooCommerce, Elementor, caching plugins) surface regularly. When a critical CVE drops, patching is not optional and not always scheduled. Budget one emergency patch cycle per year: 3–4 hours including rollback testing. That's another $240–$320.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sanity + Next.js&lt;/strong&gt; maintenance time is different in shape. Sanity's API is versioned and backward-compatible; you opt into API version bumps explicitly. The Studio is a React app you update when you choose to. Next.js releases are more frequent but the App Router surface is stable. Realistic maintenance cycle: 45–60 minutes per month, mostly &lt;code&gt;npm audit&lt;/code&gt; and dependency bumps, with no urgent plugin-level CVE exposure. Annual developer time cost: &lt;strong&gt;$720–$960&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Editor efficiency
&lt;/h2&gt;

&lt;p&gt;This one is qualitative but real. Slow editor UX has a measurable cost in time. WordPress's block editor (Gutenberg) confuses non-technical editors — I consistently see clients spend 20–30 minutes per post wrestling with block nesting, reusable block management, or broken custom field layouts. At two editors publishing twice a week, 20 wasted minutes per session adds up to roughly 138 hours/year. If those editors are marketing hires at $35/hour, that's &lt;strong&gt;~$4,800/year&lt;/strong&gt; in lost productivity.&lt;/p&gt;

&lt;p&gt;Sanity Studio with a well-structured schema is faster for editors. Portable Text keeps formatting decisions minimal. Structured fields mean editors fill in slots rather than compose layouts. The same two-editors-twice-a-week workload typically runs 5–8 minutes of friction per session in my experience — closer to &lt;strong&gt;$1,200–$2,000/year&lt;/strong&gt; in editor time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Annual cost comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cost bucket&lt;/th&gt;
&lt;th&gt;WordPress (annual)&lt;/th&gt;
&lt;th&gt;Sanity + Next.js (annual)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hosting&lt;/td&gt;
&lt;td&gt;$600–$1,200&lt;/td&gt;
&lt;td&gt;$660–$840&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plugin / license fees&lt;/td&gt;
&lt;td&gt;$800–$1,400&lt;/td&gt;
&lt;td&gt;$0–$240&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dev maintenance time&lt;/td&gt;
&lt;td&gt;$1,680–$2,240&lt;/td&gt;
&lt;td&gt;$720–$960&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Editor inefficiency&lt;/td&gt;
&lt;td&gt;~$4,800&lt;/td&gt;
&lt;td&gt;~$1,600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$7,880–$9,640&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$2,980–$3,640&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The hosting line is roughly equivalent — WordPress wins slightly at the low end, Sanity + Vercel wins slightly at the high end. Every other line favours Sanity + Next.js, and the gap widens as the team grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where WordPress still wins on running cost
&lt;/h2&gt;

&lt;p&gt;Shared hosting at $10/month plus free plugins is a real option for a solo operator with no developer on retainer and no budget for Vercel Pro. If you're running a personal site, doing your own updates, and your "editor" is yourself, WordPress's all-in cost can be $400–$600/year. Sanity + Next.js at that scale with a Hobby Vercel account and Sanity's free tier is similar — but the setup requires more upfront developer skill to self-maintain.&lt;/p&gt;

&lt;p&gt;WordPress's plugin ecosystem also covers niche requirements (LMS, WooCommerce, membership sites) that would require custom development in a Next.js stack. If your requirements fit a plugin, using it is cheaper than building.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that changes the decision
&lt;/h2&gt;

&lt;p&gt;Editor time is the swing factor for most clients. Founders often undercount it because it shows up as salaries, not invoices. Once you model it honestly, the $5,000–$6,000 annual gap between the two stacks becomes hard to ignore for any team publishing more than a handful of posts per month.&lt;/p&gt;

&lt;p&gt;If the numbers point toward switching, &lt;a href="https://nayankyada.com/blog/how-i-migrate-wordpress-to-sanity-nextjs-a-technical-walkthrough" rel="noopener noreferrer"&gt;the technical migration walkthrough&lt;/a&gt; shows what the work actually involves, and &lt;a href="https://nayankyada.com/blog/signs-your-wordpress-site-needs-a-headless-cms-rebuild" rel="noopener noreferrer"&gt;signs your WordPress site needs a headless rebuild&lt;/a&gt; helps you sanity-check whether you're there yet.&lt;/p&gt;

</description>
      <category>sanitycms</category>
      <category>nextjs</category>
      <category>comparison</category>
      <category>headlesscms</category>
    </item>
    <item>
      <title>Sanity image-url hotspot not working: four causes and fixes</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Thu, 16 Jul 2026 06:40:14 +0000</pubDate>
      <link>https://dev.to/nayankyada/sanity-image-url-hotspot-not-working-four-causes-and-fixes-4lfd</link>
      <guid>https://dev.to/nayankyada/sanity-image-url-hotspot-not-working-four-causes-and-fixes-4lfd</guid>
      <description>&lt;p&gt;Sanity's hotspot and crop system works well when all the pieces line up — but if your rendered image is ignoring the focal point you set in Studio, one of four things is almost certainly wrong. None of them are subtle bugs; they're all configuration mistakes that are easy to miss and easy to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four causes (and their fixes)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;fit&lt;/code&gt; is still set to &lt;code&gt;clip&lt;/code&gt; instead of &lt;code&gt;crop&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This is the most common cause. The &lt;code&gt;@sanity/image-url&lt;/code&gt; builder defaults to &lt;code&gt;fit('clip')&lt;/code&gt;, which scales the image to fit inside the requested dimensions without cropping anything. Hotspot data is only applied when the builder is told to &lt;em&gt;crop&lt;/em&gt; — that is, when it cuts the image down to the requested dimensions, centering the cut on the focal point.&lt;/p&gt;

&lt;p&gt;Fix: always chain &lt;code&gt;.fit('crop')&lt;/code&gt; when you pass &lt;code&gt;.width()&lt;/code&gt; and &lt;code&gt;.height()&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;// src/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="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="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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;urlFor&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="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;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="c1"&gt;// Usage — hotspot will only apply if fit is 'crop'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urlFor&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="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="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;height&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crop&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;-- required for hotspot to do anything&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="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;.fit('crop')&lt;/code&gt;, Sanity's CDN receives no crop instruction and the hotspot coordinates are silently ignored.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Missing &lt;code&gt;options: { hotspot: true }&lt;/code&gt; on the schema field
&lt;/h3&gt;

&lt;p&gt;If the image field in your Sanity schema is not configured with hotspot support, Studio never renders the focal point UI, and the &lt;code&gt;hotspot&lt;/code&gt; and &lt;code&gt;crop&lt;/code&gt; keys are never written to the document in the first place. The URL builder can't use data that isn't there.&lt;/p&gt;

&lt;p&gt;Fix: add &lt;code&gt;options: { hotspot: true }&lt;/code&gt; to every image field where editors need focal control.&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;// schemas/post.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="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;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="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;coverImage&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;image&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;hotspot&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="c1"&gt;// &amp;lt;-- enables the focal point UI in Studio&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;After adding this, existing documents will still have no &lt;code&gt;hotspot&lt;/code&gt; data — editors need to open each document and set the focal point manually, then republish.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Passing only &lt;code&gt;asset._ref&lt;/code&gt; to the URL builder instead of the full image object
&lt;/h3&gt;

&lt;p&gt;This one is common when developers destructure a GROQ projection too aggressively. The URL builder needs the full image object — &lt;code&gt;asset&lt;/code&gt;, &lt;code&gt;crop&lt;/code&gt;, and &lt;code&gt;hotspot&lt;/code&gt; — to apply the focal point. If you pass only the reference string or only &lt;code&gt;{ asset }&lt;/code&gt;, the builder has no crop/hotspot coordinates to work with.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// ❌ Wrong — only the reference, crop and hotspot are lost
"coverImage": coverImage.asset._ref

// ✅ Correct — project the full image object
"coverImage": coverImage {
  asset,
  crop,
  hotspot
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In your component, pass the full object:&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/components/CoverImage.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;urlFor&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;SanityImageObject&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;SanityImageObject&lt;/span&gt; &lt;span class="c1"&gt;// asset + crop + hotspot&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;CoverImage&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;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="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urlFor&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="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="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;height&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;630&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crop&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;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="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="o"&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="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="nx"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;630&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&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;If you use Sanity TypeGen, the inferred type for a projected image field with &lt;code&gt;crop&lt;/code&gt; and &lt;code&gt;hotspot&lt;/code&gt; will match &lt;code&gt;SanityImageObject&lt;/code&gt; automatically — a good reason to keep TypeGen up to date.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Hotspot values outside the &lt;code&gt;0–1&lt;/code&gt; range from a bad data import
&lt;/h3&gt;

&lt;p&gt;Sanity stores hotspot as &lt;code&gt;{ x, y, width, height }&lt;/code&gt; where every value is a float between 0 and 1 (fraction of the image dimensions). If you migrated content from another system or ran a custom import script, it's possible the hotspot data was written with pixel values instead of fractional values — for example &lt;code&gt;{ x: 320, y: 240, width: 640, height: 480 }&lt;/code&gt; on a 1280×960 image instead of the correct &lt;code&gt;{ x: 0.25, y: 0.25, width: 0.5, height: 0.5 }&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Pixel-valued coordinates don't cause a runtime error; the CDN just receives coordinates it can't map sensibly, so the crop lands in a wrong or extreme position.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to verify:&lt;/strong&gt; open the raw document in Sanity Studio (three-dot menu → Inspect, or &lt;code&gt;sanity documents get &amp;lt;id&amp;gt;&lt;/code&gt; via the CLI) and check the hotspot values. If any of &lt;code&gt;x&lt;/code&gt;, &lt;code&gt;y&lt;/code&gt;, &lt;code&gt;width&lt;/code&gt;, or &lt;code&gt;height&lt;/code&gt; are greater than 1, the import was wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; write a migration script using &lt;code&gt;@sanity/client&lt;/code&gt; to normalise the values.&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;// scripts/fix-hotspot-values.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;createClient&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;projectId&lt;/span&gt;&lt;span class="p"&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_PROJECT_ID&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;dataset&lt;/span&gt;&lt;span class="p"&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_DATASET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&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_WRITE_TOKEN&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2024-01-01&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;useCdn&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="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;IMAGE_WIDTH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1280&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;IMAGE_HEIGHT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;960&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;docs&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;client&lt;/span&gt;&lt;span class="p"&gt;.&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;`*[_type == 'post' &amp;amp;&amp;amp; defined(coverImage.hotspot)]{ _id, coverImage }`&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for &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;doc&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;docs&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;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coverImage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hotspot&lt;/span&gt;
  &lt;span class="c1"&gt;// Only patch if values look like pixels (&amp;gt; 1)&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;h&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;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;h&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;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;coverImage.hotspot&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;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;h&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="nx"&gt;IMAGE_WIDTH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;h&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="nx"&gt;IMAGE_HEIGHT&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="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;IMAGE_WIDTH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;IMAGE_HEIGHT&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="nf"&gt;commit&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Patched &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this against your staging dataset first. Verify a few documents in Studio before running on production.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to confirm hotspot is working
&lt;/h2&gt;

&lt;p&gt;The fastest sanity check: open browser DevTools, copy the generated image URL, and append &lt;code&gt;&amp;amp;debug=true&lt;/code&gt; — Sanity's CDN won't recognise that param, but you can manually inspect the URL for the &lt;code&gt;crop&lt;/code&gt; query string parameter. A working hotspot URL will contain something like &lt;code&gt;crop=0.1,0.05,0.1,0.05&lt;/code&gt; (left, top, right, bottom fractions). If &lt;code&gt;crop&lt;/code&gt; is absent, the builder didn't receive crop/hotspot data or &lt;code&gt;fit&lt;/code&gt; wasn't set to &lt;code&gt;crop&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You can also pass the image object through the builder and log &lt;code&gt;.options&lt;/code&gt; to confirm the builder has received all three keys before generating the URL:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urlFor&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="nf"&gt;width&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;height&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crop&lt;/span&gt;&lt;span class="dl"&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;log&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// { source: { asset, crop, hotspot }, width, height, fit }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All four of the causes above produce identical symptoms — a hotspot that looks set in Studio but has no effect on the rendered image. Work through them in order: &lt;code&gt;fit&lt;/code&gt; setting first (takes ten seconds to check), schema options second, GROQ projection third, and data integrity last.&lt;/p&gt;

</description>
      <category>sanitycms</category>
      <category>imageoptimization</category>
      <category>groq</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Sanity vs Directus for Next.js in 2026: An Honest Comparison</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Wed, 15 Jul 2026 06:34:59 +0000</pubDate>
      <link>https://dev.to/nayankyada/sanity-vs-directus-for-nextjs-in-2026-an-honest-comparison-435o</link>
      <guid>https://dev.to/nayankyada/sanity-vs-directus-for-nextjs-in-2026-an-honest-comparison-435o</guid>
      <description>&lt;p&gt;Sanity vs Directus is a comparison that comes up more than you'd expect on technical forums in 2026, usually from teams who already have a Postgres database running and are wondering why they'd pay for a separate content lake when Directus can wrap what they have. It's a fair question. These two tools solve adjacent problems but from genuinely different starting points, and the right choice depends heavily on whether your content is primarily relational data or editorial content.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each tool actually is
&lt;/h2&gt;

&lt;p&gt;Sanity is a hosted content platform. Your content lives in Sanity's managed "content lake" — a document store with real-time collaboration, a CDN-backed asset pipeline, and GROQ as the query language. You define schemas in code, deploy a customisable Studio, and talk to Sanity's API from your Next.js app. You do not manage infrastructure.&lt;/p&gt;

&lt;p&gt;Directus is an open-source data platform that wraps any existing SQL database — Postgres, MySQL, SQLite, MS SQL — and exposes it through a REST API, a GraphQL endpoint, and a web-based admin UI. Schema changes happen in the admin UI (or via migrations), and your data stays in your own database. You can self-host entirely or use Directus Cloud.&lt;/p&gt;

&lt;p&gt;That distinction — hosted content lake vs database-wrapper — drives nearly every practical difference between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data ownership and where your content lives
&lt;/h2&gt;

&lt;p&gt;With Sanity, your content lives in Sanity's infrastructure. You can export it via the export API, but you are operationally dependent on Sanity's uptime and their CDN. For most product teams that's fine — Sanity has been reliable and their SLA on Growth/Enterprise tiers is solid. But if you're in a regulated industry, have strict data residency requirements, or your client contract requires them to own the database, it's a real constraint.&lt;/p&gt;

&lt;p&gt;With Directus, the database is yours from day one. You point Directus at a Postgres instance on your own infrastructure (or a managed one like Supabase, Neon, or Railway), and Directus adds the API and admin layer on top. If you decide to stop using Directus tomorrow, your data is still in a plain relational schema you can query directly. That data portability is genuinely valuable for agencies building client sites where the client insists on owning the infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Schema approach
&lt;/h2&gt;

&lt;p&gt;Sanity schemas are defined in TypeScript files that live in your repo. You version them with Git, review them in PRs, and deploy schema changes by pushing code. The document model is flexible — nested objects, arrays of polymorphic blocks, references between documents — and Sanity TypeGen can generate TypeScript types directly from your schemas so GROQ queries are typed end-to-end.&lt;/p&gt;

&lt;p&gt;Directus takes the opposite approach: schema changes are made through the admin UI or via a JSON schema snapshot that Directus can migrate. The underlying tables change when you add a field. That's intuitive for developers who think in relational terms, but it means schema changes can be a bit more ceremonial — you need to run migrations rather than deploy a code change. Directus does have a "Schema Migration" CLI workflow that makes this repeatable, but it's a different mental model.&lt;/p&gt;

&lt;p&gt;If your team is comfortable with TypeScript-first schema design and wants schema-as-code, Sanity fits naturally into a Next.js monorepo. If your team thinks in tables and foreign keys and already has a Postgres schema they've built over years, Directus makes more sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  GROQ vs REST/GraphQL
&lt;/h2&gt;

&lt;p&gt;Sanity's query language GROQ is purpose-built for document graphs. You can project exactly the fields you want, traverse references, filter arrays, and coalesce fallbacks in a single query. In a Next.js RSC context, a single GROQ query can pull a page document, its author reference, related posts, and a filtered array of blocks — no N+1 fetching.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Fetch a page with resolved references and filtered blocks
*[_type == "page" &amp;amp;&amp;amp; slug.current == $slug][0] {
  title,
  "author": author-&amp;gt; { name, image },
  blocks[_type != "hiddenBlock"] {
    _type,
    ...
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Directus gives you REST and GraphQL. The REST API supports filtering, sorting, and field selection via query parameters, and GraphQL gives you typed queries with nested relations. Both are well-documented and widely understood. If your team already knows GraphQL, the Directus API will feel immediately familiar.&lt;/p&gt;

&lt;p&gt;The honest comparison: GROQ handles document-graph traversal with less ceremony once you know it, but it's a proprietary language with a learning curve. GraphQL is transferable knowledge. For a pure content site, GROQ wins on conciseness. For a data-heavy application with complex relational joins, Directus's GraphQL layer is more at home.&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/lib/directus.ts — fetching a page from Directus in Next.js&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;createDirectus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;readItems&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;@directus/sdk&lt;/span&gt;&lt;span class="dl"&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;directus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createDirectus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://your-directus.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;rest&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;getPage&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="nx"&gt;directus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;readItems&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="na"&gt;filter&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;_eq&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="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="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blocks&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;author&lt;/span&gt;&lt;span class="p"&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;name&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;avatar&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;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&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;h2&gt;
  
  
  Editor UX
&lt;/h2&gt;

&lt;p&gt;Sanity Studio is a React application you customise in code. The Portable Text editor is excellent for long-form content, and the presentation preview tool (where editors see their changes reflected in the live Next.js page in real time) is genuinely impressive. The trade-off is that meaningful customisation — custom input components, document actions, structure grouping — requires a developer.&lt;/p&gt;

&lt;p&gt;Directus's admin UI is a polished, general-purpose data management tool. Editors can create and manage content without any developer involvement for field-level changes, and the interface is approachable for non-technical users. It feels more like a spreadsheet/form tool than an editorial environment. For long-form content with rich text and embedded blocks, it's less capable than Portable Text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-hosting
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Sanity&lt;/th&gt;
&lt;th&gt;Directus&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hosting model&lt;/td&gt;
&lt;td&gt;Hosted SaaS (default); no self-host option&lt;/td&gt;
&lt;td&gt;Self-host on any Docker/Node infra, or Directus Cloud&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database ownership&lt;/td&gt;
&lt;td&gt;Sanity's content lake&lt;/td&gt;
&lt;td&gt;Your own SQL database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema definition&lt;/td&gt;
&lt;td&gt;TypeScript in repo&lt;/td&gt;
&lt;td&gt;Admin UI + JSON snapshots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query language&lt;/td&gt;
&lt;td&gt;GROQ (proprietary)&lt;/td&gt;
&lt;td&gt;REST + GraphQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real-time collaboration&lt;/td&gt;
&lt;td&gt;Yes (built-in)&lt;/td&gt;
&lt;td&gt;Limited (no built-in live cursors)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pricing floor (2026)&lt;/td&gt;
&lt;td&gt;Free up to 2 users / 10k docs&lt;/td&gt;
&lt;td&gt;Free self-hosted; Cloud from ~$15/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rich text editor&lt;/td&gt;
&lt;td&gt;Portable Text (excellent)&lt;/td&gt;
&lt;td&gt;WYSIWYG (adequate)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js DX&lt;/td&gt;
&lt;td&gt;First-class (typed GROQ, live preview)&lt;/td&gt;
&lt;td&gt;Good (typed SDK, REST/GraphQL)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Directus self-hosting is a legitimate option and not particularly hard to run — a single Docker Compose file with Directus and a Postgres container gets you a working instance in under 30 minutes. For agencies deploying client sites where the client wants to own the stack, that matters.&lt;/p&gt;

&lt;p&gt;Sanity has no self-host path. If that's a hard requirement, Sanity is out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one to choose for a Next.js project
&lt;/h2&gt;

&lt;p&gt;Choose Sanity if: your project is primarily editorial content (marketing pages, blog, documentation, product catalogue), your team is comfortable with TypeScript-first tooling, and you want real-time collaborative editing with a polished Studio. The Sanity + Next.js integration is the tightest in the market right now — typed queries, live preview, image pipeline, PPR-friendly revalidation.&lt;/p&gt;

&lt;p&gt;Choose Directus if: you have an existing relational database you need to expose through a CMS-style interface, your client requires full data ownership, your content is more "structured records" than "editorial pages", or you need the flexibility of GraphQL over a proprietary query language.&lt;/p&gt;

&lt;p&gt;The honest answer for most greenfield Next.js content sites in 2026: Sanity's developer experience is ahead. But Directus is not a lesser tool — it's a different tool solving a different problem, and for data-centric applications or infrastructure-owned deployments, it's often the better fit.&lt;/p&gt;

</description>
      <category>sanitycms</category>
      <category>headlesscms</category>
      <category>comparison</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Contentful pricing 2026: free tier limits, the $300 cliff, and Enterprise</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Tue, 14 Jul 2026 07:21:29 +0000</pubDate>
      <link>https://dev.to/nayankyada/contentful-pricing-2026-free-tier-limits-the-300-cliff-and-enterprise-3gii</link>
      <guid>https://dev.to/nayankyada/contentful-pricing-2026-free-tier-limits-the-300-cliff-and-enterprise-3gii</guid>
      <description>&lt;p&gt;Contentful pricing in 2026 has one problem that keeps coming up in forums and Slack groups: there is almost no middle ground between the free Community tier and a $300/month commitment. If you are scoping a project and trying to decide whether Contentful fits your budget — or comparing it against Sanity, Payload, or another CMS — this post gives you the actual numbers, what triggers each tier, and what enterprise pricing looks like in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Community free tier actually includes
&lt;/h2&gt;

&lt;p&gt;Contentful's Community plan is genuinely useful for small projects, but its limits are specific and worth knowing before you build against them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Records:&lt;/strong&gt; 25,000 records per space. A "record" in Contentful counts each published entry and each asset. A mid-size marketing site with 200 blog posts, product pages, team bios, and associated images can eat through 3,000–5,000 records faster than teams expect, especially once you factor in localised variants and component-level entries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Locales:&lt;/strong&gt; 2 locales per space. For a bilingual site this is fine. For anything requiring three or more languages — a common requirement for European SaaS companies — you are already past the free tier before you write a single line of code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Users:&lt;/strong&gt; 5 users in the Contentful web app. Fine for a freelance project, tight for an agency handing off to a client team.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Environments:&lt;/strong&gt; 2 environments (master + 1). You cannot have a staging, preview, and production environment under the free plan. Most serious Next.js projects need at least three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bandwidth and API calls:&lt;/strong&gt; The Community plan allows 500,000 API calls per month across Content Delivery API, Content Preview API, and Images API combined. A busy site using ISR with short revalidation windows can breach this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rich text and content types:&lt;/strong&gt; Up to 48 content types per space. This sounds like a lot until you start modelling component-level entries — hero blocks, feature grids, testimonials, modals — each of which typically gets its own content type.&lt;/p&gt;

&lt;h2&gt;
  
  
  The $300 jump: Contentful Basic
&lt;/h2&gt;

&lt;p&gt;This is the pricing cliff that generates most of the search frustration. There is no $49/month or $99/month tier sitting between Community and Basic. Contentful's Basic plan starts at approximately &lt;strong&gt;$300 per month&lt;/strong&gt; (billed annually; month-to-month is higher). In 2026, that figure has not moved meaningfully.&lt;/p&gt;

&lt;p&gt;What does $300 unlock?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Records:&lt;/strong&gt; 500,000 (20× increase)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Locales:&lt;/strong&gt; 5 per space&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Users:&lt;/strong&gt; 25&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environments:&lt;/strong&gt; 3 (still limited — some teams need more)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roles:&lt;/strong&gt; Basic plan adds custom roles with limited permission granularity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API call quota:&lt;/strong&gt; Increased, though Contentful does not publish an exact ceiling for Basic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a small team shipping a single marketing site, that jump is hard to justify. $300/month is $3,600/year — a cost that rivals full Vercel Pro plans plus a Sanity Growth subscription combined. The problem is not that Basic is overpriced for what it delivers; it is that there is nothing between zero and three hundred.&lt;/p&gt;

&lt;p&gt;Teams that hit the 25,000-record ceiling or the 2-locale wall mid-project have two options: upgrade immediately or restructure their data model to delay the upgrade. Neither is a comfortable conversation to have with a client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Contentful pricing table: 2026
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Monthly cost (annual)&lt;/th&gt;
&lt;th&gt;Records&lt;/th&gt;
&lt;th&gt;Locales&lt;/th&gt;
&lt;th&gt;Users&lt;/th&gt;
&lt;th&gt;Environments&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Community&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;25,000&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Basic&lt;/td&gt;
&lt;td&gt;~$300&lt;/td&gt;
&lt;td&gt;500,000&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;Custom quote&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Contentful does not publish a Pro or mid-tier plan in 2026. The table above reflects what is publicly available. Enterprise pricing is negotiated and typically involves a minimum annual commitment — figures in the $2,000–$5,000+/month range appear consistently in community discussions, though Contentful does not confirm public pricing for that tier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enterprise: what you can expect
&lt;/h2&gt;

&lt;p&gt;Contentful Enterprise is a custom contract. What that actually means in practice:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You will not get a number without a sales call.&lt;/strong&gt; Enterprise customers typically get dedicated infrastructure options, SLA guarantees (99.99% uptime), single sign-on (SSO/SAML), audit logs, and more granular RBAC. Contentful's App Framework and Compose + Launch add-ons are generally available at Enterprise tier or as paid additions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minimum commitment:&lt;/strong&gt; Anecdotally, teams report annual contracts starting around $24,000/year ($2,000/month) for smaller Enterprise agreements. Complex organisations with multiple spaces, high API volumes, and support requirements are quoted significantly higher.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spaces and environments:&lt;/strong&gt; Enterprise gives you negotiated limits on spaces and environments — this matters if you run multiple brands or clients through one Contentful organisation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contentful Studio:&lt;/strong&gt; Contentful's visual editor tooling is bundled into higher Enterprise tiers. If that is a requirement, factor it into negotiations because it is not available on Basic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparison question: Contentful vs Sanity at the same budget
&lt;/h2&gt;

&lt;p&gt;The search query "Sanity vs Contentful enterprise pricing" has no page that actually answers it with numbers, so here is a direct comparison at the $300/month mark.&lt;/p&gt;

&lt;p&gt;At $300/month, Sanity's Growth plan gives you 250,000 documents, 3 datasets (equivalent to environments), up to 20 users in Sanity Studio, and no hard API call limits — overage is charged per-use rather than gated. Sanity also has a $0 free tier with 10GB bandwidth and 2 datasets, and a ramp from free to Growth at $15/month before you hit the Growth flat rate. That graduated pricing is what Contentful does not offer.&lt;/p&gt;

&lt;p&gt;Payload CMS, which is self-hosted, has a different cost structure entirely: you pay infrastructure (a $20–50/month VPS handles most projects) rather than a SaaS tier. The trade-off is maintenance overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually triggers the upgrade
&lt;/h2&gt;

&lt;p&gt;In practice, the things that push a Contentful project from Community to Basic are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Third locale added.&lt;/strong&gt; Two locales is fine until it is not. Adding German, French, Japanese, or any third language trips the limit immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Component-level modelling.&lt;/strong&gt; Agencies that build flexible page builders in Contentful — where each section is a linked entry — accumulate records faster than teams using a flat schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple environments needed for staging.&lt;/strong&gt; A Next.js team using Contentful draft mode for Preview needs a preview environment separate from production. Two environments is not enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client team size exceeds five.&lt;/strong&gt; Marketing teams with more than five editors are common.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these triggers are unusual. They are normal requirements for a professional project. That is the real frustration: the Community plan is sized for hobby projects, and the Basic plan is sized for teams with real budget. There is nothing for the team in between.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is the $300 justified?
&lt;/h2&gt;

&lt;p&gt;For organisations with genuine scale — 10+ editors, multiple locales, high content velocity — Contentful Basic and Enterprise deliver a mature, stable platform with strong tooling, an established app marketplace, and a track record. The pricing is hard to argue with at that scale.&lt;/p&gt;

&lt;p&gt;For teams scoping a new site and trying to stay under $500/month total infrastructure cost, the jump from $0 to $300 with no intermediate option is a real planning problem. That is the constraint to design around, not the one to discover mid-engagement.&lt;/p&gt;

</description>
      <category>headlesscms</category>
      <category>comparison</category>
      <category>seo</category>
    </item>
    <item>
      <title>Payload CMS pricing 2026: the real infrastructure cost breakdown</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Mon, 13 Jul 2026 07:47:34 +0000</pubDate>
      <link>https://dev.to/nayankyada/payload-cms-pricing-2026-the-real-infrastructure-cost-breakdown-172j</link>
      <guid>https://dev.to/nayankyada/payload-cms-pricing-2026-the-real-infrastructure-cost-breakdown-172j</guid>
      <description>&lt;p&gt;Payload CMS pricing in 2026 is a trick question. The software itself is MIT-licensed — you download it, run it, own it, never pay a license fee. What you actually pay for is the infrastructure you run it on: a server or serverless platform, a database, and somewhere to store uploaded files. This post breaks that down by project size with real numbers from Railway, Fly.io, Vercel, Neon, and AWS S3.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Payload CMS actually costs (spoiler: $0 for the software)
&lt;/h2&gt;

&lt;p&gt;There is no Payload Cloud as of mid-2026. Payload 3.x ships as a Next.js plugin, so your CMS and your front-end live in the same codebase and deploy to the same host. That's architecturally elegant, but it also means you're responsible for every layer of the stack. Unlike Strapi Cloud or Sanity's hosted backend, there is no first-party managed platform you can point a credit card at and call done.&lt;/p&gt;

&lt;p&gt;The five cost buckets to plan for:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Compute&lt;/strong&gt; — where the Next.js + Payload app runs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database&lt;/strong&gt; — Postgres (recommended for Payload 3.x) or MongoDB&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File storage&lt;/strong&gt; — uploaded media, since the local disk on a serverless host is ephemeral&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bandwidth / CDN&lt;/strong&gt; — egress for media delivery&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email&lt;/strong&gt; — transactional email for Payload's auth flows (optional but usually needed)&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Hosting options compared
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Vercel&lt;/strong&gt; is the natural choice for a Next.js project. Payload 3.x runs inside Next.js route handlers, so it deploys without any special config. The Pro plan ($20/month per member) is practically mandatory once you have more than one developer or need preview deployments — the free tier's 100 GB-hours of serverless function execution runs out faster than you'd expect when Payload's API routes are warm. Edge function restrictions mean your Payload API must live in Node.js runtime, not Edge; keep that in mind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Railway&lt;/strong&gt; is the most straightforward option if you want a persistent Node.js process rather than serverless. A small always-on service runs around $5–15/month depending on RAM. Railway also offers managed Postgres, which simplifies the stack. The developer plan is $5/seat but you're usually paying per-resource, not per-seat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fly.io&lt;/strong&gt; is worth considering for teams who want full control. A shared-CPU-1x VM with 256 MB RAM is effectively free under the free allowance; a machine with 1 shared CPU and 1 GB RAM costs roughly $5–7/month. You'd run Postgres as a separate Fly app or use Neon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Render&lt;/strong&gt; sits between Railway and Fly — persistent services start at $7/month, managed Postgres at $7/month for the smallest tier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Database costs
&lt;/h2&gt;

&lt;p&gt;Payload 3.x defaults to Postgres. For most projects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Neon&lt;/strong&gt; (serverless Postgres) has a generous free tier (0.5 GB storage, 10 branches) that covers a staging environment or a small production site. The Launch plan is $19/month and includes 10 GB storage — this is where most small production sites land.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Railway Postgres&lt;/strong&gt; is $0.000231/MB-hour (~$5–8/month for a typical 2–5 GB database).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase&lt;/strong&gt; free tier is 500 MB; Pro is $25/month. Reasonable alternative if you want the dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MongoDB Atlas&lt;/strong&gt; still works with Payload 3.x but is no longer the recommended adapter. Free M0 cluster is 512 MB; M10 (the first paid, non-shared cluster) jumps to ~$57/month — a significant step up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most Payload projects in 2026, Neon Launch ($19/month) or Railway Postgres (~$7/month) is the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  File storage
&lt;/h2&gt;

&lt;p&gt;Payload's local disk storage is not usable on Vercel (ephemeral filesystem) and unreliable on most containerised hosts after deploys. You need an external storage adapter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWS S3&lt;/strong&gt; is the default recommendation. A typical editorial site with 50 GB of images and video thumbnails costs $1.15/month in storage plus $4.50/100 GB egress. For low-traffic sites the total is under $5/month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare R2&lt;/strong&gt; has zero egress fees. Storage is $0.015/GB/month. For media-heavy sites this is meaningfully cheaper than S3 — 100 GB of assets costs $1.50/month, full stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uploadthing&lt;/strong&gt; bundles storage and delivery with a simpler API. Free tier is 2 GB; the $10/month plan is 50 GB. Worth considering for small agencies that don't want to manage bucket policies.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cost table by project size
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project size&lt;/th&gt;
&lt;th&gt;Compute&lt;/th&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;Storage&lt;/th&gt;
&lt;th&gt;Monthly total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hobby / staging&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fly.io free allowance or Railway $5&lt;/td&gt;
&lt;td&gt;Neon free&lt;/td&gt;
&lt;td&gt;S3 ~$1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$6–$10&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Small production&lt;/strong&gt; (&amp;lt; 10k visits/month)&lt;/td&gt;
&lt;td&gt;Vercel Pro $20 or Railway $10&lt;/td&gt;
&lt;td&gt;Neon Launch $19&lt;/td&gt;
&lt;td&gt;R2 $2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$35–$45&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Medium&lt;/strong&gt; (10k–100k visits/month)&lt;/td&gt;
&lt;td&gt;Vercel Pro $20 + usage or Fly 2× $14&lt;/td&gt;
&lt;td&gt;Neon Launch $19&lt;/td&gt;
&lt;td&gt;R2 $5–10&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$45–$65&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Agency multi-tenant&lt;/strong&gt; (multiple clients)&lt;/td&gt;
&lt;td&gt;Dedicated VM $40–80&lt;/td&gt;
&lt;td&gt;Neon Scale $69 or self-hosted PG&lt;/td&gt;
&lt;td&gt;R2 $15–30&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$120–$180&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;High-traffic / enterprise&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fly.io performance or AWS ECS $100–200+&lt;/td&gt;
&lt;td&gt;RDS Postgres $100+&lt;/td&gt;
&lt;td&gt;S3 + CloudFront $30+&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$230+&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are infrastructure-only numbers. Add Vercel team seats ($20/member/month) if you're on Vercel Pro, and any third-party services like SendGrid ($15/month starter) or Resend ($20/month for 50k emails).&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually triggers a cost increase
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Serverless cold starts&lt;/strong&gt; on Vercel can push you into higher function execution tiers if your Payload API routes aren't cached intelligently. Profile your route handler usage before assuming the free tier is sufficient.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Media volume&lt;/strong&gt; is the most unpredictable line item. A client who uploads 4K source images without size limits will blow through an S3 budget. Set &lt;code&gt;maxFileSize&lt;/code&gt; in your Payload upload config and enforce image optimisation at the adapter level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Database connection limits&lt;/strong&gt; on Neon's free tier (max 10 connections) become a problem the moment you add a staging environment, a preview branch, and a local dev machine all hitting the same database. The Launch plan ($19/month) raises this to 100 connections — worth it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Postgres vs MongoDB&lt;/strong&gt; on cost: if you're starting fresh in 2026, use Postgres. MongoDB Atlas's lowest non-shared tier ($57/month) is hard to justify when Neon gives you a production-grade Postgres instance for $19.&lt;/p&gt;

&lt;h2&gt;
  
  
  Realistic monthly totals
&lt;/h2&gt;

&lt;p&gt;For a freelance or agency project with one client site, a realistic 2026 Payload CMS infrastructure budget is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bare minimum (hobby/staging):&lt;/strong&gt; $6–10/month&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small business production site:&lt;/strong&gt; $40–50/month&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content-heavy or multi-environment:&lt;/strong&gt; $80–120/month&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Compared to a managed headless CMS: Sanity's Growth plan is $15/month; Contentful's Basic is $300/month. Payload's infrastructure floor is competitive — but you're owning ops, updates, and backups yourself. That's a real cost even if it doesn't show up on a hosting invoice.&lt;/p&gt;

</description>
      <category>headlesscms</category>
      <category>nextjs</category>
      <category>comparison</category>
    </item>
    <item>
      <title>Strapi pricing 2026: Community edition, Cloud tiers and real infra costs</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Sun, 12 Jul 2026 07:06:22 +0000</pubDate>
      <link>https://dev.to/nayankyada/strapi-pricing-2026-community-edition-cloud-tiers-and-real-infra-costs-184i</link>
      <guid>https://dev.to/nayankyada/strapi-pricing-2026-community-edition-cloud-tiers-and-real-infra-costs-184i</guid>
      <description>&lt;p&gt;Strapi pricing in 2026 splits into two fundamentally different models: a free, self-hosted Community edition and a managed Strapi Cloud with three paid tiers. Understanding which path you're actually on — and what your real monthly spend looks like — requires looking past the marketing page and into the infra bill, the feature gates, and the support thresholds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Community edition: free but not zero cost
&lt;/h2&gt;

&lt;p&gt;Strapi's Community edition is MIT-licensed and genuinely free. You get the full content-type builder, REST and GraphQL APIs, role-based access control, plugin ecosystem, and unlimited API calls. There is no usage metering, no seat tax, no document limit.&lt;/p&gt;

&lt;p&gt;What you pay is the infrastructure layer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;VPS (2 vCPU, 2–4 GB RAM):&lt;/strong&gt; $6–12/mo on Hetzner, $12–24/mo on DigitalOcean or Render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed Postgres:&lt;/strong&gt; $7–15/mo (Supabase free tier works up to ~500 MB, then $25/mo; Neon free tier is more generous but production-grade means a paid plan).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Object storage for uploads:&lt;/strong&gt; $0–5/mo on Cloudflare R2 or Backblaze B2 for under 50 GB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total realistic floor:&lt;/strong&gt; $10–25/mo for a single-environment production setup.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a staging + production pair, double the VPS line. Add a CI/CD runner if you're deploying via GitHub Actions to a private server — that's usually free on the GitHub free tier within minutes limits.&lt;/p&gt;

&lt;p&gt;The hidden cost of self-hosting is time: Strapi updates regularly, and major versions (v4 → v5 happened in late 2024) require migration work. Budget 2–4 hours per minor upgrade cycle if you're running a customised instance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Strapi Cloud tiers in 2026
&lt;/h2&gt;

&lt;p&gt;Strapi Cloud is the managed hosting option. It handles deployment, Postgres, file storage, SSL, and rolling updates. The trade-off is a fixed monthly cost and less control over the underlying stack.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Monthly price (billed annually)&lt;/th&gt;
&lt;th&gt;Projects&lt;/th&gt;
&lt;th&gt;Seats&lt;/th&gt;
&lt;th&gt;Uploads storage&lt;/th&gt;
&lt;th&gt;Support&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Developer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$29&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;25 GB&lt;/td&gt;
&lt;td&gt;Community&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pro&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$99&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;100 GB&lt;/td&gt;
&lt;td&gt;Email (48 h)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Team&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$299&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;200 GB&lt;/td&gt;
&lt;td&gt;Priority email&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Custom / Enterprise&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Negotiated&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;td&gt;SLA + CSM&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Prices above are per-organisation, billed annually. Monthly billing adds roughly 20%. A Pro plan billed monthly runs ~$119/mo.&lt;/p&gt;

&lt;p&gt;A few line items that don't show on the marketing page until you hit them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Overages on storage&lt;/strong&gt; are charged at $0.25/GB/mo above the plan limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom domains&lt;/strong&gt; are available from the Developer tier upward — no extra charge, but you manage DNS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environments&lt;/strong&gt; (preview, staging, production) count as separate projects against your project quota. A Pro plan covers 3 projects total, so staging + production for two sites fills it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database backups&lt;/strong&gt; are included on Pro and above; Developer tier has manual snapshots only.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What actually triggers a move to Strapi Cloud
&lt;/h2&gt;

&lt;p&gt;Most developers start on self-hosted Community. Three situations consistently push teams toward Cloud:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. DevOps bandwidth runs out.&lt;/strong&gt; When the person who configured the VPS leaves, or when a security patch drops and nobody wants to own the 11 pm deployment window, a $99/mo Pro plan looks cheap relative to incident cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Client handoff requires a supported URL.&lt;/strong&gt; Agencies delivering to non-technical clients need a stable admin URL, SSL that renews itself, and someone other than the developer to call when Strapi goes down. Cloud satisfies all three without a custom ops setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The project adds a second environment.&lt;/strong&gt; Adding staging to a self-hosted setup means a second VPS, second Postgres instance, a deploy script, and environment variable management. On Cloud, it's a second project slot — already included on Pro.&lt;/p&gt;

&lt;p&gt;The move away from Cloud happens too, usually when an organisation hits the Team tier ($299/mo) and realises their traffic pattern doesn't justify it. At that point, a dedicated $40/mo VPS with managed Postgres at $25/mo ($65/mo total) looks better, especially if they have a developer who'll own the ops.&lt;/p&gt;

&lt;h2&gt;
  
  
  Strapi Cloud vs self-hosted: total cost comparison
&lt;/h2&gt;

&lt;p&gt;For a single-project, 3-editor content team running one environment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted Community:&lt;/strong&gt; $10–25/mo in infra, zero licence. Suits teams with a developer who will own the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strapi Cloud Developer:&lt;/strong&gt; $29/mo all-in. Suits solo freelancers or small teams who want zero ops overhead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strapi Cloud Pro:&lt;/strong&gt; $99/mo. Suited to agencies managing 2–3 client sites where client uptime expectations are contractual.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a two-environment (staging + prod) setup with 5 editors, Pro is usually the correct Cloud tier, and self-hosted remains competitive at $20–40/mo if you're disciplined about maintenance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision guide by team size
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Solo freelancer or side project:&lt;/strong&gt; Self-hosted Community on a $6 Hetzner VPS. The Developer Cloud tier at $29/mo is a reasonable upgrade if you want zero maintenance. Skip Cloud Pro — the project slots and email support aren't worth it at this scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Startup (2–8 person team, one product):&lt;/strong&gt; Self-hosted is viable if a technical co-founder owns the server. Cloud Pro at $99/mo is worth it when the team lacks server confidence or when staging environment friction slows down marketing. The break-even against self-hosted ($20–40/mo) is roughly two developer-hours of avoided ops work per month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agency (3–15 client sites):&lt;/strong&gt; Cloud Pro covers 3 projects — not enough for most active agency workloads without reusing project slots. Cloud Team at $299/mo covers 10 projects and makes sense if you're billing infrastructure management as part of a retainer. Alternatively: self-host a multi-tenant Strapi instance (one codebase, multiple content roots via environments) — complex to set up, cheaper at scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enterprise (compliance, SLA):&lt;/strong&gt; Negotiate Custom/Enterprise directly. Strapi's enterprise agreement includes SSO, audit logs, a dedicated CSM, and an uptime SLA. This is where the Community edition's MIT licence matters: if you need Strapi on-premise behind a corporate firewall, you can do it. No other managed-CMS player gives you that option at the same price point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Strapi v5 migration cost
&lt;/h2&gt;

&lt;p&gt;If you're on a legacy Strapi v4 instance evaluating a move to v5 (the current major version as of mid-2026), factor in migration effort: content-type schema changes, plugin compatibility audits, and API response format differences. For a moderately customised v4 instance, budget 8–20 hours of developer time. This cost applies regardless of whether you move to Cloud or stay self-hosted.&lt;/p&gt;

</description>
      <category>headlesscms</category>
      <category>comparison</category>
    </item>
    <item>
      <title>Sanity vs Hygraph 2026: an honest side-by-side for Next.js teams</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Sat, 11 Jul 2026 06:32:51 +0000</pubDate>
      <link>https://dev.to/nayankyada/sanity-vs-hygraph-2026-an-honest-side-by-side-for-nextjs-teams-3740</link>
      <guid>https://dev.to/nayankyada/sanity-vs-hygraph-2026-an-honest-side-by-side-for-nextjs-teams-3740</guid>
      <description>&lt;p&gt;Sanity vs Hygraph is one of the cleaner matchups in headless CMS right now because the two products make genuinely different bets. Sanity bets on a content lake with a flexible query language and a programmable studio. Hygraph (formerly GraphCMS) bets on GraphQL as the native interface and content federation across external APIs. Neither is a poor choice for Next.js — but they suit different project shapes, and picking the wrong one costs real time.&lt;/p&gt;

&lt;p&gt;This post runs through the areas that matter most in practice: data model and querying, editor UX, Next.js integration, pricing, and where each tool breaks down.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Sanity and Hygraph actually are
&lt;/h2&gt;

&lt;p&gt;Sanity is a hosted content platform. Your schemas live in a TypeScript config that you version-control. GROQ is the query language — it runs against Sanity's hosted content lake and returns exactly the shape you ask for. The Sanity Studio is an open-source React app you embed into your repo (or deploy separately) and customise at component level.&lt;/p&gt;

&lt;p&gt;Hygraph is a hosted GraphQL-native CMS. The schema is configured through a web UI and exposed via a GraphQL API. The standout feature is &lt;strong&gt;content federation&lt;/strong&gt;: Hygraph can stitch in data from external REST or GraphQL APIs — Shopify, Stripe, a custom microservice — and surface it as a unified GraphQL schema alongside your CMS content. Editors never see the difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Querying: GROQ vs GraphQL
&lt;/h2&gt;

&lt;p&gt;For most Next.js data fetching this is the most consequential difference.&lt;/p&gt;

&lt;p&gt;GROQ lets you project exactly the fields you want, follow references, apply conditional logic, and flatten nested arrays in a single round trip. A typical page query might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// groq — fetch a page with resolved author and only the fields needed for SSG
*[_type == "post" &amp;amp;&amp;amp; slug.current == $slug][0] {
  title,
  publishedAt,
  "author": author-&amp;gt;{ name, "avatar": image.asset-&amp;gt;url },
  body[] {
    ...,
    _type == "imageBlock" =&amp;gt; {
      "url": asset-&amp;gt;url,
      "lqip": asset-&amp;gt;metadata.lqip,
      "dimensions": asset-&amp;gt;metadata.dimensions
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No over-fetching, no multiple round trips for references. Sanity TypeGen generates TypeScript types from that query, so your RSC components are fully typed without manual interfaces.&lt;/p&gt;

&lt;p&gt;Hygraph's GraphQL API is spec-compliant and predictable. If your team already lives in GraphQL and uses a client like urql or graphql-request, the integration feels natural. The schema-first approach means generated types through tooling like GraphQL Code Generator work well. Where Hygraph GraphQL can feel awkward is deeply nested reference traversal — you need to plan your schema to avoid over-fetching, and rich document fields like long-form structured content require more query boilerplate than a GROQ projection.&lt;/p&gt;

&lt;p&gt;Content federation is Hygraph's genuine advantage here. If your Next.js app needs to pull product data from Shopify and editorial content from the CMS into a single component without a custom aggregation layer, Hygraph's Remote Sources feature handles that cleanly. With Sanity you'd write that aggregation yourself in a route handler or server component.&lt;/p&gt;

&lt;h2&gt;
  
  
  Editor experience
&lt;/h2&gt;

&lt;p&gt;Sanity's editor experience is highly configurable but requires developer time to configure well. Out of the box it is functional; with a week of studio customisation — custom input components, structure builder grouping, live preview via Presentation — it becomes excellent. Portable Text is the richest structured content model available in any headless CMS; editors get a Word-like experience with fine-grained control over embedded components.&lt;/p&gt;

&lt;p&gt;Hygraph's editor is cleaner to set up for non-developers. Schema changes happen in the UI, no code deployment required. The Rich Text editor is solid and the component model is simpler than Portable Text, which is a reasonable trade-off for teams who want to onboard editors quickly without developer involvement. The limitation is customisation ceiling — you cannot extend the editor UI the way you can with Sanity Studio components.&lt;/p&gt;

&lt;p&gt;For multilingual content, both platforms support localisation, but Sanity's document-level internationalisation plugin and Hygraph's built-in locale management are both mature. Hygraph has a slight edge in UI clarity for locale switching; Sanity gives more schema-level control.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Sanity&lt;/th&gt;
&lt;th&gt;Hygraph&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Data fetching&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@sanity/client&lt;/code&gt; or &lt;code&gt;next-sanity&lt;/code&gt; with GROQ&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;graphql-request&lt;/code&gt; or any GraphQL client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type safety&lt;/td&gt;
&lt;td&gt;Sanity TypeGen from GROQ queries&lt;/td&gt;
&lt;td&gt;GraphQL Code Generator from schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Draft / preview&lt;/td&gt;
&lt;td&gt;Draft Mode with live preview via Presentation plugin&lt;/td&gt;
&lt;td&gt;Content Stages + preview URLs (no Studio overlay)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ISR revalidation&lt;/td&gt;
&lt;td&gt;Webhooks to &lt;code&gt;/api/revalidate&lt;/code&gt; with HMAC&lt;/td&gt;
&lt;td&gt;Webhooks to &lt;code&gt;/api/revalidate&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image CDN&lt;/td&gt;
&lt;td&gt;Sanity image pipeline (auto format, hotspot, LQIP)&lt;/td&gt;
&lt;td&gt;Hygraph asset API (transformations, but fewer presets)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On-demand ISR&lt;/td&gt;
&lt;td&gt;First-class, well-documented&lt;/td&gt;
&lt;td&gt;Supported via webhooks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sanity's image pipeline is deeper. LQIP hashes, hotspot focal crops, and automatic AVIF/WebP negotiation are built into the asset metadata and easy to use with &lt;code&gt;next/image&lt;/code&gt;. Hygraph's asset transformations cover the basics — resize, crop, format — but metadata like blur hash requires additional setup.&lt;/p&gt;

&lt;p&gt;Both CMSes work well with Next.js App Router RSC. Neither requires a client component for data fetching. Sanity's &lt;code&gt;next-sanity&lt;/code&gt; package handles streaming-compatible clients; with Hygraph you use a standard &lt;code&gt;fetch&lt;/code&gt; wrapper with &lt;code&gt;graphql-request&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing
&lt;/h2&gt;

&lt;p&gt;Sanity's free tier allows up to 3 users, 2 datasets, and generous API quotas for most small projects. The Growth plan starts at $15/month per seat. The content lake pricing model means API calls and dataset size are the main cost levers — predictable for content-heavy sites, less so for high-traffic APIs.&lt;/p&gt;

&lt;p&gt;Hygraph's free tier is more limited on operations (API calls per month) and project count. Paid plans start around $199/month for the Scale tier, which puts it out of reach for freelance or small agency projects. The pricing model is designed for enterprise teams where content federation replaces custom integration work — at that scale, the cost is justified. For a standard marketing site or blog, Sanity's pricing is considerably more accessible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where each one breaks down
&lt;/h2&gt;

&lt;p&gt;Sanity's weaknesses: the studio requires developer investment to reach its potential. Teams without a developer to maintain schema changes can struggle. GROQ has a learning curve for engineers coming from GraphQL. And if you need to federate data from external APIs at the CMS layer, Sanity has no equivalent to Hygraph's Remote Sources.&lt;/p&gt;

&lt;p&gt;Hygraph's weaknesses: schema changes via the UI mean no Git history for content model evolution — a real operational pain on larger teams. The pricing floor makes it hard to justify for smaller scopes. Rich text and structured content modelling is less expressive than Portable Text. Editor customisation is limited compared to what you can build in Sanity Studio.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one to pick
&lt;/h2&gt;

&lt;p&gt;Choose Sanity if your project is content-heavy, your team will invest in studio setup, you want the deepest image pipeline, and your budget is constrained at the start. It fits most Next.js marketing sites, blogs, and product documentation projects.&lt;/p&gt;

&lt;p&gt;Choose Hygraph if your architecture already uses GraphQL across services, you need content federation to unify external APIs without writing a custom aggregation layer, and you're working inside an enterprise team where the $199/month floor is not a barrier. The GraphQL-native approach and federation feature are genuinely differentiated — but you're paying for them.&lt;/p&gt;

&lt;p&gt;For the majority of Next.js projects I scope, Sanity is the right default. Hygraph earns its place in a specific scenario: composable commerce or multi-service data unification where GraphQL federation does real architectural work.&lt;/p&gt;

</description>
      <category>sanitycms</category>
      <category>headlesscms</category>
      <category>comparison</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Contentful alternatives for Next.js in 2026: Sanity, Payload, Strapi, Storyblok, Hygraph</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Fri, 10 Jul 2026 07:58:24 +0000</pubDate>
      <link>https://dev.to/nayankyada/contentful-alternatives-for-nextjs-in-2026-sanity-payload-strapi-storyblok-hygraph-1b9</link>
      <guid>https://dev.to/nayankyada/contentful-alternatives-for-nextjs-in-2026-sanity-payload-strapi-storyblok-hygraph-1b9</guid>
      <description>&lt;p&gt;The most common reason teams reach out to me about a CMS migration is Contentful sticker shock — they hit the Enterprise tier wall and realise the bill is climbing past $3,000/month for a mid-sized marketing site. The second reason is that Contentful's schema editor lives entirely in the GUI, which means schema drift, no code review, and no reliable rollback. If either of those sounds familiar, this post maps out the five contentful alternatives for Next.js that I'd actually recommend in 2026, who each one suits, and where each one falls short.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why teams leave Contentful
&lt;/h2&gt;

&lt;p&gt;Contentful is not a bad product. For non-technical editors who want a polished GUI and don't need a developer to babysit the CMS, it genuinely delivers. The problems surface at scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing cliff.&lt;/strong&gt; The free tier caps at 25,000 records and 2 users. The next meaningful tier (Team) is ~$300/month, and the moment you need SSO, custom roles, or environments beyond sandbox, you're on Enterprise with a custom quote. Most teams I talk to are paying $1,800–$5,000/month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No code-first schemas.&lt;/strong&gt; Content types live in the Contentful dashboard. There is no &lt;code&gt;schema.ts&lt;/code&gt; you can version-control or generate TypeScript from. This makes multi-environment promotion manual and fragile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GraphQL is add-on pricing.&lt;/strong&gt; The REST Content Delivery API is included; the GraphQL API is a paid add-on that counts against API calls differently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vendor lock-in on hosted infrastructure.&lt;/strong&gt; You cannot self-host. If Contentful raises prices again — and they have — you migrate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is fatal for a funded team with a dedicated DevOps budget. But for a product team or agency building on Next.js App Router, there are now better-fit options.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five alternatives
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Sanity
&lt;/h3&gt;

&lt;p&gt;Sanity defines schemas in TypeScript (&lt;code&gt;defineType&lt;/code&gt;, &lt;code&gt;defineField&lt;/code&gt;) and stores the source of truth in your git repo. Sanity TypeGen generates fully typed GROQ query results, so your &lt;code&gt;sanity.types.ts&lt;/code&gt; is a first-class artifact alongside your Next.js code. The hosted Studio (React-based, fully customisable) ships inside your Next.js repo at &lt;code&gt;/studio&lt;/code&gt; via the App Router.&lt;/p&gt;

&lt;p&gt;For Next.js specifically, Sanity has the tightest integration story: ISR revalidation via webhooks, draft mode with Presentation, image hot-spot and LQIP metadata baked into asset records, and a real-time content lake that supports streaming SSR naturally. GROQ is more expressive than GraphQL for relational content without n+1 penalties.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fits:&lt;/strong&gt; Product and agency teams building content-heavy Next.js sites where developer experience, type safety, and editor customisation all matter. Also the right call when content modelling is complex (multi-locale, multi-tenant, reusable page blocks).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; The GROQ learning curve is real if your team only knows GraphQL. The free tier (3 users, 2 datasets) is generous for small projects but the Growth plan at ~$15/user/month adds up on larger editorial teams.&lt;/p&gt;

&lt;h3&gt;
  
  
  Payload CMS
&lt;/h3&gt;

&lt;p&gt;Payload is code-first and self-hosted (or deployed to Payload Cloud). Schemas are TypeScript config files. It generates its own types automatically. The admin panel is a React app you can extend. It ships its own REST and GraphQL APIs and, from Payload 3.x, runs natively inside a Next.js App Router project — the same &lt;code&gt;next dev&lt;/code&gt; process runs both the CMS admin and your front-end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fits:&lt;/strong&gt; Teams that want full ownership of the stack, need custom back-end logic (order management, auth flows, custom endpoints), or are working in a regulated environment where data residency matters. Excellent for a small team that also needs a lightweight back-end API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; You're managing your own database (Postgres or MongoDB), deployments, and upgrades. Payload Cloud reduces that burden but it's still younger than Contentful's infrastructure. Editor UI is functional but not as polished as Sanity Studio.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strapi
&lt;/h3&gt;

&lt;p&gt;Strapi is the most widely used open-source headless CMS. It has a GUI-based content type builder (like Contentful) but you can also define types via code files. Deployable anywhere, supports SQLite/Postgres/MySQL. Strapi 5 brought improved TypeScript support and a new document service API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fits:&lt;/strong&gt; Teams coming from WordPress or Drupal who want something familiar. Also good when a non-developer needs to create content types without writing code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; Strapi's TypeScript story is still catching up to Payload's. The plugin ecosystem is wide but quality varies. For complex Next.js ISR setups, the webhook layer needs custom work. Strapi Cloud exists but many teams self-host and then underestimate the ops burden.&lt;/p&gt;

&lt;h3&gt;
  
  
  Storyblok
&lt;/h3&gt;

&lt;p&gt;Storyblok is a visual CMS — editors see their content rendered in a live preview sidebar as they edit components. Its component model maps directly to page-builder blocks. It has a Next.js SDK and supports ISR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fits:&lt;/strong&gt; Marketing teams and agencies building landing pages where non-technical editors need to assemble layouts without developer involvement. If the main complaint about Contentful is that editors can't see what they're editing, Storyblok is the most direct fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; Schema is defined in the Storyblok GUI (same code-drift problem as Contentful). The content API can be verbose for complex relational content. Pricing scales by seats and API calls; at high traffic you'll need a CDN caching strategy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hygraph (formerly GraphCMS)
&lt;/h3&gt;

&lt;p&gt;Hygraph is a federated content platform — it can stitch your Contentful content together with other data sources (Shopify, Salesforce) via a unified GraphQL layer. If your Contentful usage was mostly driven by its GraphQL API and you have multiple upstream data sources, Hygraph is the cleanest migration path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fits:&lt;/strong&gt; Teams with a GraphQL-first architecture, especially e-commerce or enterprise projects combining product data with editorial content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; Hygraph's free tier is limited. The federated layer adds latency and complexity that most content sites don't need. It's the most niche of the five.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side-by-side table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Sanity&lt;/th&gt;
&lt;th&gt;Payload&lt;/th&gt;
&lt;th&gt;Strapi&lt;/th&gt;
&lt;th&gt;Storyblok&lt;/th&gt;
&lt;th&gt;Hygraph&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Schema definition&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Code (TypeScript)&lt;/td&gt;
&lt;td&gt;Code (TypeScript)&lt;/td&gt;
&lt;td&gt;GUI + code files&lt;/td&gt;
&lt;td&gt;GUI only&lt;/td&gt;
&lt;td&gt;GUI only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Self-hostable&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No (Studio in repo, data in cloud)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Next.js integration depth&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;★★★★★&lt;/td&gt;
&lt;td&gt;★★★★★&lt;/td&gt;
&lt;td&gt;★★★☆☆&lt;/td&gt;
&lt;td&gt;★★★★☆&lt;/td&gt;
&lt;td&gt;★★★☆☆&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Editor experience&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Excellent (visual)&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Free tier&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Generous (3 users)&lt;/td&gt;
&lt;td&gt;Open source (self-host)&lt;/td&gt;
&lt;td&gt;Open source (self-host)&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TypeScript types&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Auto-generated (TypeGen)&lt;/td&gt;
&lt;td&gt;Auto-generated&lt;/td&gt;
&lt;td&gt;Manual / partial&lt;/td&gt;
&lt;td&gt;SDK types&lt;/td&gt;
&lt;td&gt;SDK types&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best for Contentful migrators who…&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Want code-first schemas + rich studio&lt;/td&gt;
&lt;td&gt;Want full self-hosted control&lt;/td&gt;
&lt;td&gt;Want familiar GUI, open source&lt;/td&gt;
&lt;td&gt;Want visual editing for marketers&lt;/td&gt;
&lt;td&gt;Have GraphQL-first + multi-source needs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Which one to pick
&lt;/h2&gt;

&lt;p&gt;If the main pain with Contentful is pricing and you want a better developer experience: &lt;strong&gt;Sanity&lt;/strong&gt;. The TypeScript-first workflow, GROQ, and tight Next.js App Router integration make it the default recommendation for most projects I scope.&lt;/p&gt;

&lt;p&gt;If you need to own your data and your infrastructure: &lt;strong&gt;Payload&lt;/strong&gt;. Especially if you're also building custom API endpoints alongside your content layer.&lt;/p&gt;

&lt;p&gt;If your editors are the bottleneck and they want to assemble pages visually: &lt;strong&gt;Storyblok&lt;/strong&gt;. Accept the GUI-only schema trade-off; the editor productivity gain is real.&lt;/p&gt;

&lt;p&gt;If Contentful's GraphQL API was the thing your team loved and you have federated data sources: &lt;strong&gt;Hygraph&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you want the lowest long-term licensing cost and your team is comfortable with self-hosted ops: &lt;strong&gt;Strapi&lt;/strong&gt; or &lt;strong&gt;Payload&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Migrating CMS mid-project is expensive regardless of which direction you go. The schema design decision at the start of a Next.js project determines how painful that migration becomes — which is why code-first, version-controlled schemas are worth prioritising from day one.&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;// sanity/schemas/article.ts — code-first schema, lives in git&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;articleSchema&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;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;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;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="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;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;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@latest typegen generate` produces sanity.types.ts from this&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single file — committed, reviewed, and diffed like any other TypeScript — is the clearest illustration of why teams moving off Contentful land on Sanity or Payload rather than another GUI-first CMS.&lt;/p&gt;

</description>
      <category>headlesscms</category>
      <category>nextjs</category>
      <category>comparison</category>
      <category>sanitycms</category>
    </item>
    <item>
      <title>How I migrate WordPress to Sanity + Next.js: a technical walkthrough</title>
      <dc:creator>Nayan Kyada</dc:creator>
      <pubDate>Thu, 09 Jul 2026 08:03:44 +0000</pubDate>
      <link>https://dev.to/nayankyada/how-i-migrate-wordpress-to-sanity-nextjs-a-technical-walkthrough-kcj</link>
      <guid>https://dev.to/nayankyada/how-i-migrate-wordpress-to-sanity-nextjs-a-technical-walkthrough-kcj</guid>
      <description>&lt;p&gt;Migrating WordPress to Sanity + Next.js is mostly a data engineering problem. The decisions around &lt;em&gt;whether&lt;/em&gt; to migrate are covered elsewhere — this post is purely about the mechanics: getting content out of WordPress, shaping it into typed Sanity documents, and making sure Google never sees a broken URL.&lt;/p&gt;

&lt;p&gt;I'll walk through the four stages I use on every migration: export, schema mapping, import scripting, and redirect wiring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exporting WordPress content
&lt;/h2&gt;

&lt;p&gt;WordPress gives you two export paths. The WXR (WordPress eXtended RSS) XML file you get from Tools → Export is fine for small sites, but it serialises everything into one flat file and the HTML inside &lt;code&gt;&amp;lt;content:encoded&amp;gt;&lt;/code&gt; nodes is a pain to parse. I prefer the REST API for anything above a few hundred posts.&lt;/p&gt;

&lt;p&gt;For a typical site with posts, pages, and categories I pull:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://old-site.com/wp-json/wp/v2/posts?per_page=100&amp;amp;page=1
https://old-site.com/wp-json/wp/v2/pages?per_page=100&amp;amp;page=1
https://old-site.com/wp-json/wp/v2/categories?per_page=100
https://old-site.com/wp-json/wp/v2/media?per_page=100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I paginate through all of them and write each collection to a local JSON file. The &lt;code&gt;media&lt;/code&gt; endpoint is worth fetching separately — it gives you &lt;code&gt;source_url&lt;/code&gt;, &lt;code&gt;alt_text&lt;/code&gt;, and &lt;code&gt;mime_type&lt;/code&gt; for every attachment, which you need for image migration.&lt;/p&gt;

&lt;p&gt;One gotcha: custom post types registered by plugins won't appear under &lt;code&gt;/wp/v2/posts&lt;/code&gt;. Check &lt;code&gt;wp-json/wp/v2&lt;/code&gt; for the full namespace list and add any CPTs you find.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mapping WordPress types to Sanity schemas
&lt;/h2&gt;

&lt;p&gt;WordPress has a flat mental model: everything is a post with a post type. Sanity wants explicit document types with typed fields. I do the mapping on paper before writing a single line of import code.&lt;/p&gt;

&lt;p&gt;Typical equivalences:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;WordPress&lt;/th&gt;
&lt;th&gt;Sanity document type&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;post&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;blogPost&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;page&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;page&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;category&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;category&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tag&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tag&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;attachment&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;inlined as &lt;code&gt;image&lt;/code&gt; field or &lt;code&gt;figure&lt;/code&gt; block&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Sanity schemas for &lt;code&gt;blogPost&lt;/code&gt; and &lt;code&gt;category&lt;/code&gt; look like this in my projects:&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/blogPost.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;blogPostSchema&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;blogPost&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;Blog 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;Rule&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;Rule&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;categories&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;reference&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;to&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;category&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="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;excerpt&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;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&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="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;image&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;seo&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;object&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;metaTitle&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="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;metaDescription&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;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&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;canonicalUrl&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;url&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;legacySlug&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="c1"&gt;// stores original WP slug&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;Notice &lt;code&gt;seo.legacySlug&lt;/code&gt;. I always store the original WordPress slug on every document. It makes the redirect matrix trivial to generate later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing the import script
&lt;/h2&gt;

&lt;p&gt;The import script has three jobs: upload images to Sanity's asset store, convert HTML body content to Portable Text, and create the documents.&lt;/p&gt;

&lt;p&gt;I use &lt;code&gt;@sanity/client&lt;/code&gt; directly and the &lt;code&gt;sanity-block-content-to-hyperscript&lt;/code&gt; / &lt;code&gt;html-to-portable-text&lt;/code&gt; ecosystem. The cleanest library I've found for the HTML conversion is &lt;code&gt;@portabletext/html-input&lt;/code&gt; — but it's new and the API shifts. A more stable option is running the WordPress HTML through &lt;code&gt;htmlparser2&lt;/code&gt; and mapping nodes manually. For most blog content the tags involved are limited enough that a manual mapper is 100 lines and fully predictable.&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;// scripts/migrate.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;createClient&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;JSDOM&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;jsdom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;fs&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;node:fs&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;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;projectId&lt;/span&gt;&lt;span class="p"&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_PROJECT_ID&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2024-06-01&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&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_WRITE_TOKEN&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;useCdn&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="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wpPosts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WpPost&lt;/span&gt;&lt;span class="p"&gt;[]&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;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./export/posts.json&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;utf8&lt;/span&gt;&lt;span class="dl"&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;wpMedia&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;WpMedia&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./export/media-map.json&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;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&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;uploadImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sourceUrl&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;altText&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sourceUrl&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;buffer&lt;/span&gt; &lt;span class="o"&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;assets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image&lt;/span&gt;&lt;span class="dl"&gt;'&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="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sourceUrl&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;pop&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;altText&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;function&lt;/span&gt; &lt;span class="nf"&gt;htmlToBlocks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&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;PortableTextBlock&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;dom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JSDOM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&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;blocks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PortableTextBlock&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
  &lt;span class="k"&gt;for &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;node&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;dom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;children&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;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tagName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&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;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p&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;h2&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;h3&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;h4&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;blockquote&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&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="na"&gt;_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p&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;normal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;children&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;span&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;marks&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;markDefs&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;// extend: handle &amp;lt;ul&amp;gt;, &amp;lt;ol&amp;gt;, &amp;lt;figure&amp;gt; etc.&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;blocks&lt;/span&gt;
&lt;span class="p"&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;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &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="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;wpPosts&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;featuredMedia&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="nx"&gt;featured_media&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;wpMedia&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;featured_media&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;featuredImageRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&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;featuredMedia&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;asset&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;uploadImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;featuredMedia&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;featuredMedia&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alt_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nx"&gt;featuredImageRef&lt;/span&gt; &lt;span class="o"&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;image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;asset&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;reference&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;_ref&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&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;doc&lt;/span&gt; &lt;span class="o"&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;blogPost&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`wp-post-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rendered&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="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;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&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="na"&gt;publishedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date_gmt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;excerpt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;excerpt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rendered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&amp;gt;/g&lt;/span&gt;&lt;span class="p"&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;trim&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;htmlToBlocks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rendered&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;featuredImage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;featuredImageRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;seo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;legacySlug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&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="na"&gt;canonicalUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;link&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;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createOrReplace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Imported: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;run&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&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 noting here. I prefix &lt;code&gt;_id&lt;/code&gt; with &lt;code&gt;wp-post-&lt;/code&gt; so re-running the script is idempotent — &lt;code&gt;createOrReplace&lt;/code&gt; won't create duplicates. I upload images before creating the document so the reference is ready. The &lt;code&gt;htmlToBlocks&lt;/code&gt; function above is a skeleton — you'll need to add handlers for &lt;code&gt;&amp;lt;ul&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;ol&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;strong&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;em&amp;gt;&lt;/code&gt;, and &lt;code&gt;&amp;lt;figure&amp;gt;&lt;/code&gt; based on what's in the actual content. Dump a sample post and audit the tags before writing the full mapper.&lt;/p&gt;

&lt;p&gt;Run this script locally against a &lt;code&gt;staging&lt;/code&gt; dataset first, never production. Check a random 10% of posts in Sanity Studio before promoting to production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserving SEO with a 301 redirect matrix
&lt;/h2&gt;

&lt;p&gt;WordPress URLs are usually &lt;code&gt;/year/month/post-slug/&lt;/code&gt; or just &lt;code&gt;/post-slug/&lt;/code&gt; depending on the permalink setting. Next.js App Router typically uses &lt;code&gt;/blog/[slug]&lt;/code&gt;. That's a different path structure, so every old URL needs a 301.&lt;/p&gt;

&lt;p&gt;After the import is done I query Sanity for every &lt;code&gt;legacySlug&lt;/code&gt; and the new slug in one GROQ query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// GROQ query to generate redirect map
*[_type == "blogPost"] {
  "from": seo.legacySlug,
  "to": slug.current
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I export that as a JSON array and write it into &lt;code&gt;next.config.ts&lt;/code&gt; as static redirects for small sites, or into a middleware lookup for large ones. The &lt;code&gt;next.config.ts&lt;/code&gt; approach is simpler and gets baked into the Vercel edge config:&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;// next.config.ts — for sites with &amp;lt; 1000 legacy URLs&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;NextConfig&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&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;redirectMap&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;./redirects.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// generated from GROQ export&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;redirects&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;redirectMap&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="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="p"&gt;})&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;source&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="k"&gt;from&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="c1"&gt;// e.g. /old-post-slug&lt;/span&gt;
      &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;to&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="c1"&gt;// e.g. /blog/new-post-slug&lt;/span&gt;
      &lt;span class="na"&gt;permanent&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="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;default&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For sites with category archives at &lt;code&gt;/category/[slug]/&lt;/code&gt; I build the same matrix for &lt;code&gt;category&lt;/code&gt; documents. WordPress tag pages at &lt;code&gt;/tag/[slug]/&lt;/code&gt; usually don't need preserving unless they have inbound links — check Search Console before deciding.&lt;/p&gt;

&lt;p&gt;Once the redirects are live I submit a new sitemap via Search Console and run a crawl with Screaming Frog to verify there are no chains (301 → 301) and no 404s. Category and pagination pages from WordPress that have no equivalent in the new site should redirect to the nearest logical parent — usually &lt;code&gt;/blog&lt;/code&gt; or &lt;code&gt;/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The whole migration for a 300-post blog typically takes two focused days: half a day on export and schema design, half a day on the import script, and one day on redirect audit and staging QA.&lt;/p&gt;

</description>
      <category>sanitycms</category>
      <category>nextjs</category>
      <category>groq</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
