<?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: Jono</title>
    <description>The latest articles on DEV Community by Jono (@jonoroboto).</description>
    <link>https://dev.to/jonoroboto</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%2F3923565%2Faf492404-67c4-4fe5-b9c2-32408e0149ac.jpeg</url>
      <title>DEV Community: Jono</title>
      <link>https://dev.to/jonoroboto</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jonoroboto"/>
    <language>en</language>
    <item>
      <title>Four surprises from writing a BigCommerce to Sanity catalog sync</title>
      <dc:creator>Jono</dc:creator>
      <pubDate>Thu, 13 Aug 2026 14:57:16 +0000</pubDate>
      <link>https://dev.to/jonoroboto/four-surprises-from-writing-a-bigcommerce-to-sanity-catalog-sync-21cm</link>
      <guid>https://dev.to/jonoroboto/four-surprises-from-writing-a-bigcommerce-to-sanity-catalog-sync-21cm</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; We built &lt;a href="https://github.com/robotostudio/turbo-start-bigcommerce" rel="noopener noreferrer"&gt;turbo-start-bigcommerce&lt;/a&gt;, where a BigCommerce catalog syncs into Sanity and page-builder blocks reference the synced documents. Four things caught us out: webhooks that structurally can't do the job, an API that lies about page size, deletes needing their own program, and ids that belong to the other system. All four are in the open-source package if you'd rather read the code.&lt;/p&gt;

&lt;p&gt;Every integration where content in one system references entities in another has the same shape, and it's always more work than it looks.&lt;/p&gt;

&lt;p&gt;Ours is &lt;a href="https://github.com/robotostudio/turbo-start-bigcommerce" rel="noopener noreferrer"&gt;turbo-start-bigcommerce&lt;/a&gt;: the catalog lives in BigCommerce, the editorial content lives in Sanity, and page-builder blocks hold references across the gap. The sync is &lt;a href="https://github.com/robotostudio/turbo-start-bigcommerce/tree/main/packages/sanity-sync" rel="noopener noreferrer"&gt;packages/sanity-sync&lt;/a&gt;, it's open source, and these are the four things we didn't see coming.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Webhooks structurally can't do the job
&lt;/h2&gt;

&lt;p&gt;The plan is always webhooks. Entity changes over there, you get told, you update over here.&lt;/p&gt;

&lt;p&gt;Here's the comment we ended up writing at the top of &lt;code&gt;reconcile.ts&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;packages/sanity-sync/src/reconcile.ts&lt;/code&gt;&lt;/strong&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="cm"&gt;/**
 * The sweep is the primary sync mechanism, not a fallback. BigCommerce has no
 * CRUD webhooks for variants and none for brands, and most product image
 * changes — including changing the thumbnail — fire no update event at all.
 * Webhook payloads are id-only, unordered, and can duplicate. A webhook-only
 * sync is therefore structurally incomplete; webhooks are at best a latency
 * optimisation layered on top of this sweep.
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mechanism everyone budgets for turns out to be the one that can't do the job alone. That isn't a knock on BigCommerce. Partial webhook coverage is normal across catalog and asset APIs, and the gaps are never in the documentation. You discover them by noticing a thumbnail that didn't change.&lt;/p&gt;

&lt;p&gt;Which means you write the sweep anyway. And once the sweep exists, the webhook becomes an optimisation you might not bother shipping.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The API lies about page size
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;packages/sanity-sync/src/reconcile.ts&lt;/code&gt;&lt;/strong&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="cm"&gt;/**
 * Admin REST caps a catalog page at 50 — but silently drops it to 10 the moment
 * `options` or `modifiers` are included. Verified against the sandbox:
 * `?limit=50&amp;amp;include=variants,options,images` comes back `per_page: 10`.
 */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PAGE_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&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;PAGE_SIZE_WITH_OPTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ask for 50, get 10, no warning. Drive your pagination loop off the limit you requested rather than the &lt;code&gt;total_pages&lt;/code&gt; the server reports and you'll sync a fifth of the catalog while your logs report success.&lt;/p&gt;

&lt;p&gt;Categories have a different quirk. That resource has no &lt;code&gt;date_modified:min&lt;/code&gt; filter at all, and asking for one returns &lt;code&gt;422 The filter(s): date_modified:min are not valid filter parameter(s)&lt;/code&gt;, so categories get swept whole every time regardless of whether you asked for an incremental run.&lt;/p&gt;

&lt;p&gt;Two unrelated quirks, one vendor, on precisely the two resources we needed. Neither is difficult to handle once you know. Finding out is the expensive part, and finding out is not on anybody's estimate.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Deletes are a separate program
&lt;/h2&gt;

&lt;p&gt;An upsert sweep tells you what exists. It says nothing about what stopped existing, so there's a second pass:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;packages/sanity-sync/src/reconcile.ts&lt;/code&gt;&lt;/strong&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;live&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="nx"&gt;fetch&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*[_type in $types &amp;amp;&amp;amp; store.isDeleted != true]._id&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;types&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="s2"&gt;bigcommerceProduct&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="s2"&gt;bigcommerceProductVariant&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="s2"&gt;bigcommerceCategory&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;staleMutations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sweep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything Sanity still holds, minus everything the catalog just returned, is stale. We soft-delete rather than hard-delete, because content may still reference those documents and a tombstone beats a hole.&lt;/p&gt;

&lt;p&gt;The pass can only run on a full sweep. Run it after an incremental one and every unmodified entity looks deleted, because the sweep never saw it in the first place. So it's guarded:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;packages/sanity-sync/src/reconcile.ts&lt;/code&gt;&lt;/strong&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="k"&gt;if &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="nx"&gt;since&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Incremental sweep — skipping the soft-delete pass.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;softDeleted&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;sweepDeletes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sweep&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One &lt;code&gt;if&lt;/code&gt;, standing between you and soft-deleting your entire catalog on a cron.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The ids aren't yours
&lt;/h2&gt;

&lt;p&gt;This is the one that generalises hardest, and the one that bit us in production.&lt;/p&gt;

&lt;p&gt;Synced documents get deterministic ids: &lt;code&gt;bigcommerceProduct-{entityId}&lt;/code&gt;, where &lt;code&gt;entityId&lt;/code&gt; is whatever BigCommerce minted when the product was created. Every store counts from its own starting point. The crewneck that's &lt;code&gt;181&lt;/code&gt; on the store we captured content from is some other number on yours, so a committed reference to &lt;code&gt;bigcommerceProduct-181&lt;/code&gt; is correct on exactly one store on earth and dangling everywhere else.&lt;/p&gt;

&lt;p&gt;And a dangling weak reference, as our seed docs now say in as plain a form as we could manage:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A dangling weak reference renders as nothing — no error, no gap in the log, just an empty navbar.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No stack trace, no alert. A homepage quietly rendering four fewer products than it should, until someone scrolls past it on a Tuesday.&lt;/p&gt;

&lt;p&gt;So the seed data contains no ids. It contains placeholders named by slug, &lt;code&gt;bigcommerceProduct-bramley-wool-crewneck&lt;/code&gt;, and a separate command resolves them against whatever the sync actually wrote:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm &lt;span class="nb"&gt;sync&lt;/span&gt;:bigcommerce    &lt;span class="c"&gt;# catalog out of BigCommerce, into Sanity&lt;/span&gt;
pnpm seed:refs &lt;span class="nt"&gt;--write&lt;/span&gt;   &lt;span class="c"&gt;# repoint the content at the ids this store minted&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An entire extra build step, whose only reason to exist is that the identifier belongs to the other system. It's idempotent, since it only rewrites references whose tail isn't already numeric, and it's all-or-nothing, because half a remap leaves a dataset that is neither the old state nor the new one.&lt;/p&gt;

&lt;p&gt;It also carries a trap we had to write down: no slug may be entirely numeric. Something like &lt;code&gt;bigcommerceProduct-2024&lt;/code&gt; looks like an id that has already been resolved, so the remap skips it, and it stays dangling. Empty navbar, no error, eighteen months later nobody remembers why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why any of this matters beyond commerce
&lt;/h2&gt;

&lt;p&gt;Swap products for images and every item above survives the translation. That's the case against bolting a separate asset manager onto your CMS, and I've written that argument up properly with the numbers attached: &lt;a href="https://dev.to/jonoroboto/the-expensive-part-of-a-dam-isnt-the-licence-its-the-sync-job-11me"&gt;the DAM cost procurement forgets&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The general rule we took away: before you agree to an integration, work out whether a reference from your content to the other system's entity will be a real reference your CMS understands, or a string. If it's a string, everything above is on your roadmap whether it's on the estimate or not.&lt;/p&gt;

&lt;p&gt;The whole package is MIT and the tests are in there too. Take it, that's what a starter is for.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Building commerce on Sanity and Next.js?&lt;/strong&gt; Structured content modelling, catalog integrations, and migrations off legacy stacks. &lt;a href="https://www.robotostudio.com/services/sanity" rel="noopener noreferrer"&gt;See our Sanity service&lt;/a&gt;. Expect some of it to come back as a recommendation to spend nothing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I use the sync package outside the starter?&lt;/strong&gt;&lt;br&gt;
It's written against this repo's schema, but the shape is portable: a paginated sweep, an upsert path shared with the single-entity sync, a soft-delete pass, and an id remap. Most of the value is in the structure rather than the BigCommerce specifics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why soft-delete instead of removing the document?&lt;/strong&gt;&lt;br&gt;
Because content may still reference it. A soft-deleted document lets the front end render a tombstone or skip the item deliberately, rather than resolving a reference to nothing and rendering an empty space nobody notices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do you run the sweep on a schedule?&lt;/strong&gt;&lt;br&gt;
Nothing invokes it in the starter, deliberately. You run it by hand or wire it to whatever scheduler you use. The single-entity path exists so you can reproduce a webhook delivery from a terminal before the route exists.&lt;/p&gt;




&lt;p&gt;I'm Jono. I run &lt;a href="https://www.robotostudio.com" rel="noopener noreferrer"&gt;Roboto Studio&lt;/a&gt;. Next.js and Sanity mostly, plus a lot of time spent unpicking integrations that seemed reasonable at the time. If you've written a sync like this, I'd like to hear which surprise got you.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>sanity</category>
      <category>nextjs</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>The DAM cost procurement forgets, in code</title>
      <dc:creator>Jono</dc:creator>
      <pubDate>Thu, 13 Aug 2026 14:34:29 +0000</pubDate>
      <link>https://dev.to/jonoroboto/the-expensive-part-of-a-dam-isnt-the-licence-its-the-sync-job-11me</link>
      <guid>https://dev.to/jonoroboto/the-expensive-part-of-a-dam-isnt-the-licence-its-the-sync-job-11me</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; DAM quotes land in the mid five figures a year, and that figure doesn't include the integration. The moment your site depends on assets living in someone else's system, you own a sync pipeline: partial webhook coverage, a reconciliation sweep, a delete pass, and ids you didn't mint. We've written that pipeline. It's more work than the demo call implies, and it's the strongest argument for keeping assets in your content graph.&lt;/p&gt;

&lt;p&gt;There's a meeting on every enterprise headless build where someone asks where the DAM goes. We wrote up the commercial answer to that question in &lt;a href="https://www.robotostudio.com/blog/mux-sanity-do-you-really-need-a-dam" rel="noopener noreferrer"&gt;Mux + Sanity: do you really need a DAM?&lt;/a&gt;, and the short version is that most teams already own a DAM through Sanity's Media Library, and that dedicated licences run to $30k–70k a year for a system that still can't stream video.&lt;/p&gt;

&lt;p&gt;One line in that post's FAQ gets a single sentence and deserves a whole article:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;That second cost is the one procurement forgets: a DAM your site depends on needs a sync pipeline, webhook handling, and someone who owns it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is that article. Not the licence, the pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The distinction that decides everything
&lt;/h2&gt;

&lt;p&gt;An asset in Sanity's Media Library is a document. Your front end queries it like any other document, and a reference to it from a page is a real reference the CMS understands.&lt;/p&gt;

&lt;p&gt;An asset in a bolted-on DAM is, from your CMS's point of view, a string. A URL or an external id, sitting in a field, pointing at a row in someone else's database. As the original post puts it, it's "a file behind someone else's API that you mirror into your CMS and hope stays in sync."&lt;/p&gt;

&lt;p&gt;That word &lt;em&gt;hope&lt;/em&gt; is doing a lot of work, and closing the gap it describes is an engineering project. Here's what's in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Webhooks won't cover it
&lt;/h2&gt;

&lt;p&gt;The plan is always webhooks. The asset changes over there, you get told, you update over here.&lt;/p&gt;

&lt;p&gt;We build commerce integrations with exactly this shape, where a catalog lives in one system and the content referencing it lives in Sanity. Here's the comment we ended up writing at the top of our sync package, after finding out how much the events actually cover:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;packages/sanity-sync/src/reconcile.ts&lt;/code&gt;&lt;/strong&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="cm"&gt;/**
 * The sweep is the primary sync mechanism, not a fallback. BigCommerce has no
 * CRUD webhooks for variants and none for brands, and most product image
 * changes — including changing the thumbnail — fire no update event at all.
 * Webhook payloads are id-only, unordered, and can duplicate. A webhook-only
 * sync is therefore structurally incomplete; webhooks are at best a latency
 * optimisation layered on top of this sweep.
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's BigCommerce, not a DAM, but the shape is the vendor-agnostic one. Webhook coverage is partial, the gaps aren't in the documentation, and you find them by noticing a thumbnail that didn't change. Which means you write a scheduled sweep that walks the whole library and compares it against what you're holding. Once the sweep exists, the webhook is a latency optimisation you may not bother shipping.&lt;/p&gt;

&lt;p&gt;Nobody scopes the sweep on the demo call. It's the actual deliverable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the parts that only show up later
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Deletes are a second program.&lt;/strong&gt; An upsert sweep tells you what exists and says nothing about what stopped existing. So you need a subtraction pass: everything you hold, minus everything the library just returned, is stale. Ours soft-deletes rather than hard-deletes, because content may still reference those documents and a tombstone beats a hole. It also has to be guarded so it only ever runs on a full sweep, because after an incremental one every unmodified asset looks deleted. That guard is one &lt;code&gt;if&lt;/code&gt; standing between you and wiping the library on a cron.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ids aren't yours.&lt;/strong&gt; Assets get their identity from the DAM. Anything you seed, commit, or move between environments is carrying an identifier minted by a system you don't control, so you need a resolution step that repoints content at whatever ids this environment actually has. We ship one as a separate build command. It exists purely because the identifier belongs to someone else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alt text has to be copied.&lt;/strong&gt; You're not making a third-party HTTP call during a server render, so asset metadata gets denormalised into your CMS. Now you hold two copies and a job whose entire purpose is keeping them equal, and every field marketing adds over there is a schema change over here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing tells you when it breaks.&lt;/strong&gt; This is the one that stings. A reference pointing at an id that no longer resolves doesn't throw. In our stack it renders as nothing at all, which we wrote into the seed docs in as plain a form as we could manage:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A dangling weak reference renders as nothing — no error, no gap in the log, just an empty navbar.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No stack trace, no alert. A homepage quietly rendering four fewer things than it should, until somebody scrolls past it on a Tuesday.&lt;/p&gt;

&lt;p&gt;The whole pipeline is open source if you want to read it rather than take our word for it, and I've written up the four things that caught us out building it: &lt;a href="https://dev.to/jonoroboto/four-surprises-from-writing-a-bigcommerce-to-sanity-catalog-sync-21cm"&gt;Four surprises from writing a BigCommerce to Sanity catalog sync&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is worth in the DAM decision
&lt;/h2&gt;

&lt;p&gt;Put the two costs next to each other. The licence is $30k–70k a year. The pipeline above is a build, plus an owner, plus an on-call story, indefinitely. Procurement prices the first and rarely the second, which is how a DAM ends up looking cheaper than it is.&lt;/p&gt;

&lt;p&gt;None of it exists if assets are documents in the same content graph as your content. There's no webhook because there's no second system, no reconciliation because there's nothing to reconcile, and referential integrity is just referential integrity, because the CMS knows what uses what.&lt;/p&gt;

&lt;p&gt;Usual disclosure: Sanity is the stack we build on and sell, so weigh our enthusiasm accordingly. The architecture question survives whatever CMS you're on. Ask it of Contentful, Payload or Strapi and the answer will tell you what you're signing up for.&lt;/p&gt;

&lt;p&gt;Video is the honest exception, and it splits the other way. Storing a video and delivering one are unrelated problems, and no DAM solves the second, so a specialist service earns its place there whatever you do about images. We wrote up the player side separately, lazy-loaded facade and all: &lt;a href="https://www.robotostudio.com/blog/a-really-nice-mux-video-component" rel="noopener noreferrer"&gt;a really nice Mux video component&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Modelling content before you buy tooling.&lt;/strong&gt; Structured content modelling, migrations off legacy stacks, and the unglamorous business of deciding what should be a reference and what shouldn't. &lt;a href="https://www.robotostudio.com/services/sanity" rel="noopener noreferrer"&gt;See our Sanity service&lt;/a&gt;. Expect some of it to come back as a recommendation to spend nothing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The question to ask in the meeting
&lt;/h2&gt;

&lt;p&gt;Not "what features does it have". This:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Will a reference from a page to an asset be a real reference the CMS understands, or a string?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the answer is a string, ask who owns the sweep, who's on call for it, and what happens when the two systems disagree. Those questions all have answers. They're just expensive, and they're much cheaper to hear before the contract than after.&lt;/p&gt;

&lt;p&gt;The genuine exception holds up. If several disconnected projects share one asset operation and most of the consumers aren't on your CMS, a central DAM is doing work nothing else is doing, and the build cost really can climb past the licence. That's a smaller share of teams than the sales cycle implies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Isn't a sync pipeline just the cost of any integration?&lt;/strong&gt;&lt;br&gt;
Mostly it's the cost of &lt;em&gt;stateful&lt;/em&gt; integrations. Plenty of services are request-response: you call them, you get an answer, nothing needs reconciling afterwards. Asset systems are different because durable content holds references to their records, so the two systems have to agree over time rather than at one moment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Our DAM ships a CMS plugin. Doesn't that cover it?&lt;/strong&gt;&lt;br&gt;
It covers the picker, usually the metadata copy, sometimes the sweep. It can't give you referential integrity, because that would need your CMS to own the asset record, which is the thing the DAM exists to prevent. Read the plugin's source and check what it does on a delete.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if the DAM is already bought?&lt;/strong&gt;&lt;br&gt;
Count the consumers. If everything downstream is a single CMS, you're paying a licence plus the engineering tax above to keep two systems agreeing. If genuinely disconnected systems consume those assets, it's earning its keep and this post isn't about you.&lt;/p&gt;




&lt;p&gt;I'm Jono. I run &lt;a href="https://www.robotostudio.com" rel="noopener noreferrer"&gt;Roboto Studio&lt;/a&gt;. Next.js and Sanity mostly, plus a lot of time spent unpicking integrations that seemed reasonable at the time. If you've written the sync pipeline, I'd like to hear which part caught you out.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>architecture</category>
      <category>cms</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Agentic eCommerce with Shopify and Sanity</title>
      <dc:creator>Jono</dc:creator>
      <pubDate>Wed, 17 Jun 2026 21:04:48 +0000</pubDate>
      <link>https://dev.to/jonoroboto/agentic-ecommerce-with-shopify-and-sanity-26k9</link>
      <guid>https://dev.to/jonoroboto/agentic-ecommerce-with-shopify-and-sanity-26k9</guid>
      <description>&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Axvtvf1t-fY"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Turbo Start Aisle is our agentic e-commerce starter on Shopify and Sanity. Instead of filters and category pages, you chat with an agent that builds the shopping UI as you go, narrowing by brand, size, and colour, and answering questions like the return policy in plain language. In the video I shop for tea and let the agent do the filtering.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is agentic e-commerce?
&lt;/h3&gt;

&lt;p&gt;It's a way for customers to interact with your store by talking to an AI agent instead of clicking through filters and category menus. You ask for what you want in plain language, whether that's a product, a size, a colour, a brand, or a policy question, and the agent answers in real time.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do agents generate UI in e-commerce?
&lt;/h3&gt;

&lt;p&gt;The agent reads your request and renders the matching products and options on the spot, rather than sending you to a pre-built category page. You see only what fits what you asked for.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I ask agents about product specifics like size and color?
&lt;/h3&gt;

&lt;p&gt;Yes, that's most of what the demo does. Ask whether a product comes in a given size, colour, or brand and the agent checks inventory and shows what's in stock. It narrows things down through conversation instead of stacked filters.&lt;/p&gt;

&lt;h3&gt;
  
  
  What else can agentic e-commerce agents help with besides finding products?
&lt;/h3&gt;

&lt;p&gt;More than product search. You can ask about returns, shipping, or warranties and get a plain-language answer, or ask anything else about how the store works.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does this compare to traditional e-commerce filtering?
&lt;/h3&gt;

&lt;p&gt;Traditional e-commerce makes you navigate categories and set the filters yourself. With an agent you describe what you need and it does the searching and filtering for you in the chat.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is agentic e-commerce only for small websites?
&lt;/h3&gt;

&lt;p&gt;We demoed it on a small store, but it suits large catalogues just as well. The more products and categories you have, the more an agent saves customers from hunting through menus.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I try agentic e-commerce?
&lt;/h3&gt;

&lt;p&gt;We built Turbo Start Aisle on Shopify and Sanity as a working example. Open the chat, describe what you want, and the agent helps you narrow it down.&lt;/p&gt;




&lt;h2&gt;
  
  
  Transcript
&lt;/h2&gt;

&lt;p&gt;Hey, how's it going? Today I'm going to talk about Agentic e-commerce, what it means, and how it's going to completely shape the way users interact with your e-commerce website. In today's example, I will show you how the agents will work to generate UI on the fly and it would be completely the opposite of the traditional way of using filters and categories in your e-commerce website. So let's have a look.&lt;/p&gt;

&lt;p&gt;Okay, so I'm going to try to find a tea for myself. And I'm at Turbo at Isle, which is Robboto's e-commerce website built with Shopify and Sanity. So let's go to the chat. And I'm going to first of all find out what brands they have. Oh, great. So they have Robboto. Perfect. That's the top end of my budget. 50 quid tea is still quite expensive. Oh, thank God they have three options. Great. Do you have any in small? Oh, amazing. Do you have in small and pink? Pink, not whatever I typed. Oh, great. So let's have a look. Okay, so they have Tazmin in pink and in my size. So this is a great example of just showing how you would interact with agents in an e-commerce website.&lt;/p&gt;

&lt;p&gt;You can also probably find out what the policy is like, return policy. So, um, explain written policy to me like I am a two-year-old. Okay, so that's pretty straightforward. So this is one example of how you would interact with agents in your e-commerce website. Obviously you can do it in many different ways and Turbo at Isle is a very, you know, small website with some products and categories. But if it's a large e-commerce, the world is your oyster. Go crazy with it. Hope you enjoyed today's video. Thank you so much for watching.&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>sanity</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Sanity SEO done right: Open Graph, JSON-LD, fallbacks</title>
      <dc:creator>Jono</dc:creator>
      <pubDate>Tue, 19 May 2026 20:25:57 +0000</pubDate>
      <link>https://dev.to/jonoroboto/sanity-seo-done-right-open-graph-json-ld-fallbacks-32dg</link>
      <guid>https://dev.to/jonoroboto/sanity-seo-done-right-open-graph-json-ld-fallbacks-32dg</guid>
      <description>&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/nG7IgrhaJBY"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Every Sanity project we ship starts with the same SEO baseline: a title and description on the document, an SEO tab that overrides them when needed, and Open Graph fields one layer deeper. Fallbacks all the way down, so the page always renders something sensible even when an editor forgets a field. Here's the exact pattern we use, and where to find it in Turbostart Sanity if you want to copy it.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why does Sanity feel so unopinionated about SEO?
&lt;/h3&gt;

&lt;p&gt;Because Sanity has to model anything — a blog, an e-commerce catalogue, a screen that shows live train times. The trade-off for that range is that the platform won't pick an SEO pattern for you. You bring the opinion; Sanity gives you the schema.&lt;/p&gt;

&lt;h3&gt;
  
  
  What fields should every page document have as an SEO baseline?
&lt;/h3&gt;

&lt;p&gt;Title and description at the top level, used as both the page heading/blurb and the default meta. A fallback SEO image. Then a separate SEO tab with &lt;code&gt;seoTitle&lt;/code&gt;, &lt;code&gt;seoDescription&lt;/code&gt;, &lt;code&gt;seoImage&lt;/code&gt;, &lt;code&gt;seoNoIndex&lt;/code&gt;, and &lt;code&gt;seoHideFromLists&lt;/code&gt; for overrides. Open Graph fields sit a layer deeper for the cases where you want social cards to differ from search snippets.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why have an SEO tab if the title and description already exist on the document?
&lt;/h3&gt;

&lt;p&gt;Consistency. Editors get one place to write the page copy and one place to override the meta when SEO needs something different — without leaving fields blank or duplicating data. The top-level fields always act as the fallback, so nothing renders empty.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should JSON-LD live in the CMS or be generated automatically?
&lt;/h3&gt;

&lt;p&gt;Generate it. If the author, title, description, and publish date are already structured in Sanity, map them straight into JSON-LD in your Next.js page. Set and forget. Editors never touch it, and the structured data stays in lockstep with the content.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does &lt;code&gt;seoHideFromLists&lt;/code&gt; actually do?
&lt;/h3&gt;

&lt;p&gt;It's a flag that lets editors keep a page indexable but exclude it from listing pages. Useful for landing pages tied to paid campaigns, or one-off blog posts you don't want surfacing on the blog index. Filter on it in your GROQ query and the page disappears from lists without needing &lt;code&gt;noIndex&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Transcript
&lt;/h2&gt;

&lt;p&gt;Hey bud, how you doing? So, first of all, I wanted to put this on YouTube so everybody else can actually see the answer to this question because I get asked this so many times on a daily basis, but I thought it might be helpful for you.&lt;/p&gt;

&lt;p&gt;Uh, so in short, and this is a very very common thing that we get: has anybody done SEO work on Sanity before? It doesn't really go too much into what's actually built into the model, but I hear this time and time again. Um, and the reason why you have this happen so much is because Sanity is very very unopinionated. And the reason why it's very very unopinionated is because it can create models that can do anything — genuinely anything when it comes to just writing a blog, or it can be more complex as to running an entire, like, e-commerce system, or it can be as simple as creating, like, a terminal that tells you the latest train times at a train station. It's really really um scalable.&lt;/p&gt;

&lt;p&gt;Anyway, so what I'll talk about is a little bit of this. So this is an old learn article that we wrote with Sanity, which is really helpful. It tells you about how you should opinionate your actual SEO, and it's all good and fine if you want to take the time out to go look through this, that's great. You've got a lot more time than I have, so that's fantastic.&lt;/p&gt;

&lt;p&gt;Now, I'm going to show you something totally different. This is actually the Turbo Art, and this is the way that we do it. It's the same thing that's in the Sanity learn documentation. Um, but I know how low people's attention spans are today, so very, very quickly, I'll show you how this works. This is how we opinionate SEO and we do the exact same on every single one of our projects.&lt;/p&gt;

&lt;p&gt;So, when we build a page for a page builder or for a blog or any other document type, it has a title. Okay? And you can do validation. You can do things like, oh, it has to be x number of characters. You can do that. That's really cool if you think it would help the client. But for the simplicity of this, we don't have, like, that little number underneath um because we're trying to be unopinionated.&lt;/p&gt;

&lt;p&gt;Um, and description, which is going to be effectively used as your meta description on the page. This can also double up. So if, for example, it's a blog, um, the title of the blog could also be the title that sits inside of here, and the description could be the little blurb that you get just before you click into the blog on a card. That's one thing to know. So this becomes our baseline. The same with images. So you may see an image in here as well. Um, which I believe is an SEO — so ours is a SEO image override. Um, however, sometimes we actually include the image in here as well. It could be that it should be in here. Nope. Okay. Um, that might get tweaked on the Turbo Art Sanity, but essentially I like to have an image inside of here as well as part of the pages as a fallback image.&lt;/p&gt;

&lt;p&gt;However, we have this secondary tab which is SEO. This is your SEO, your meta title. So your title tags at the top, um, and the meta description which would be in the meta description section. Um, and these will override the title and the description. And you might ask, hold up, if we've got the title and description, then what's going to happen if we override them? Then there surely there's no reason to have these in the first place. Well, the reason we have these is because they're always a fallback. So it means that you have a consistent way of editing your pages, whether that's a page with page builder blocks or a blog which has, like, the blog title, blog description. So it may not appear on all pages.&lt;/p&gt;

&lt;p&gt;Now we've got our SEO. So our metas, what about our open graph? Well, makes perfect sense. Let's do open graph title, open graph description. And you can go a step further and also do, I think there's a separate open graph image. Uh, you can separate it out down to as granular level as here's what we want to have on Twitter, here's what we want to have on Facebook, and I think LinkedIn does it as well, just off the top of my head. Um, this is kind of like our de facto baseline. So I would highly, highly recommend doing it this way. Um, however, you can go a lot more granular. You can put JSON LD in there. You can do all sorts of things. However, a note on JSON LD, and I've said this to a lot of different people: I much, much prefer set and forget JSON LD. So if you're already putting all this data, like titles and descriptions and things like that, in there, and authors — if you're on a blog page — then if you're on a blog page, let me show you a quick example of this. Um, I'm wondering if we have authors on here. I hope we do. If we don't, I'm going to tell you how to do it. Uh, we do. Great. So if we've got all of these authors that are inside of here, why don't we just pull those authors into JSON LD by default, which is set and forget? So you don't have to think about it. You just enter the data inside of Sanity, and a like-for-like pair comes up with JSON LD. And it's not so complicated these days with AI. So you can even do this relatively quickly. Um, but yeah, this is what I'm talking about when I talk about a de facto default SEO experience. What you should use as a baseline for every single Sanity website that is going to be a website. This obviously differs if it's going to be a different function. But if you're looking to start this straight away, or in your case, I would just literally go to the Sanity exchange, go find Turbo Art Sanity. You can also find this on GitHub. Um, go view the repo and tell the developer to just go look inside of here. Um, and pay special attention to two files. One of them is query. So query shows you how we handle our GROQ queries. And GROQ is the query language from Sanity. And then the second thing you want to pay attention to inside of here, um, is — let me see if I can find it for you. Let me just press up top any second now — is the SEO. I think it's SEO fields, which you can see. You can reuse this. This is what we use on ours where it's the SEO title, SEO description. Makes perfect sense. SEO image, SEO no index, and SEO hide from lists. And the reason we have these two left is because obviously you want to have a no index. Sometimes maybe it's a pay-per-click content page that you very specifically want to, like, tunnel down um a specific person that's coming to your website. And the second one is SEO hide from lists because, for example, you have a blog post. It may be that you don't want that blog post to show up on certain lists. It may be that you have a no index and you also don't want it to show up on certain lists, or vice versa. That's the reason why we do this because if you have SEO hide from lists inside of your GROQ, you can say give me all the blogs except for the ones that are getting hidden from lists, and it's really nice and easy to work with.&lt;/p&gt;

&lt;p&gt;Uh, there's some very basic validation here, but I hope this helps with you trying to solve this SEO problem. If it continues, just give us a shout. We're more than happy to try and help and give you pointers in the right direction. Thanks a lot. Have a great day. Take care. Bye-bye.&lt;/p&gt;

</description>
      <category>sanity</category>
      <category>seo</category>
      <category>nextjs</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
