Dylan Jhaveri published both of these on 27 March 2020. One is a post on Sanity’s blog. The other is a Sanity guide that the post points at for the import script. He was at Mux, writing on the vendor’s site. Read them as a pair, because the counts do not match.
The blog post says they had about 130 posts. The guide says they migrated 128 existing posts, over a couple of months. I keep both numbers. Neither page is a downtime log or a redirect map.
Before this, the blog was an old Ghost install, hosted and deployed apart from mux.com. The marketing site was already Gatsby. It built some pages from Markdown with gatsby-source-filesystem, and pulled other data with gatsby-source-airtable. The Ghost → Sanity playbook is the wider ledger, authors through cutover. These two pages are the schema and the HTML converter.
What Ghost would not do
Three limits pushed the move.
The Ghost theme could not match the marketing site. It was a different application, with its own hosting and its own deploy. A visual tweak on the blog did not ride the Gatsby build.
A Ghost post had one author. Some Mux posts were written by more than one person, and they wanted every name on the page.
Tags had gone fuzzy. Duplicate tags with slightly different names, and authors unsure which one to pick. They wanted about seven categories, and one category per post.
Sanity won because it felt like a structured database they could shape, not a finished blog product. The same store could later hold other mux.com pages, including a video glossary. Sanity already had a Mux plugin for upload, transcode, and playback. He does not describe a second CMS they tried.
One collection for authors and for the team page
The types are post and teamMember. They skipped the name author because the same records had to render /team. A public boolean hides someone from that page without deleting them. A post holds an array of references, so one post can name several people.
Cover images are encouraged and not quite required. mainImage fails validation unless the document has an image or imageNotRequired is true. The error string is Add a main image. The switch is the escape hatch for a post that honestly has nothing to show. SEO fields sit in their own fieldset. Title maxes at 70 characters. Description is 50 to 200. publishedAt is set when you hit publish, and you can override it.
The blog post says the body needed 11 custom types, with examples muxCta, youtubeEmbed, and images, plus a footnote annotation. The guide lists the HTML cases the importer had to recognize: a divider that was an hr, Gist embeds, CTA buttons, Twitch, YouTube, CodeSandbox, Mux video, tables, images, footnotes, and code blocks. That is also eleven. Footnotes are an annotation in the blog post and a list item in the guide. I would not collapse those into one schema diagram.
The guide shows the CTA as two strings, text and href. Mux video comes from sanity install mux-input, then a mux.video member on the block array. Headings in the studio are styled inline, 28px for h1 and 21px for h2, so the editor looks closer to the site.
HTML in, portable text out, ten posts at a time
Ghost’s export was mux-blog.ghost.json. It has authors and posts, and each post in both Markdown and HTML. They converted the HTML.
@sanity/block-tools turns HTML into portable text if you hand it a schema and a parser. They parse with JSDOM. A deserialize hook inspects each element. A pre becomes a codeBlock with language: 'text' and the element’s text. An iframe whose src matches gist.github.com becomes a gistEmbed, with the id taken from a regex and an optional file query param. If the id is missing, the snippet logs an error and returns undefined, so that embed is dropped rather than imported as a broken object.
Each document is written as NDJSON. The id is post-${post.uuid}, the type is post, and slug, SEO, body, and publishedAt come off the Ghost record. Stable ids are why the import can use --replace. The command in both pages is:
sanity dataset import sanity-import.ndjson production --replace
He says that step gets slow when there are a lot of images. For this blog it was tolerable. The guide says he imported in groups of 10, checked, then continued. The blog post says batches of 5 or 10, then Sanity queries in the Gatsby app, then a look at structure and styling, then fixes, then the next batch. The script itself took trial and error. The guide’s printed processPost is labeled simplified, and the object it writes uses title and slug that are not in the destructure above them. I would not paste that function in and expect it to run.
gatsby-source-sanity behaved like the other Gatsby sources. The snag was @sanity/image-url without a sanityClient in hand. He names the snag and does not show the workaround.
Drafts stayed in Google Docs, then in Gatsby Cloud
Writing did not move into Sanity. Drafts stayed in Google Docs, because suggestions and inline comments already worked and the company was on G Suite. After review, someone pastes into Sanity. Basic text and links survive the paste. Images, code blocks, and the custom types are still manual. He hoped they would draft in one place someday. This write-up does not say they got there.
Locally, gatsby-source-sanity runs with overlayDrafts and watchMode. Gatsby Cloud’s real-time preview is what the rest of the company sees. Publish in Sanity fires a webhook that rebuilds production.
The studio then got two extra panes, only on post, through the desk-structure API. Web Preview is an iframe pointed at the Gatsby Cloud preview, under /blog/. The SEO pane is mostly copied from a Sanity proof-of-concept repo and shows a search result, a Twitter card, and a Facebook card. The guide’s sample preview host is the placeholder https://your-preview-url.is. Brand CSS sets --brand-primary to #FF2B61.
What these pages do not give you is the cutover. No old-host 301 list, no lost-comment count, no hour when Ghost stopped answering. The gain he describes is the one they designed for. Several authors, a short category list, and a blog that builds with mux.com instead of beside it.