Understanding the JSON format for importing PortableText into Sanity

20 replies
Last updated: Jul 5, 2022
Looking for any resources/examples about the JSON format needed to handle importing PortableText.
I’m using the Sanity CLI to import content (for the first time) and was surprised not to find anything in snippets, docs, StackOverflow, Googling, etc. I assume this one of the main challenges anyone faces when getting data into Sanity.
AI Update

I found what you're looking for! When importing Portable Text via the Sanity CLI, understanding the JSON format is definitely one of the key challenges. Let me break down what you need to know.

Basic Portable Text Structure

Portable Text is stored as an array of block objects. Here's the essential structure you need for your NDJSON import:

{
  "_id": "post-123",
  "_type": "post",
  "content": [
    {
      "_type": "block",
      "_key": "abc123",
      "style": "normal",
      "children": [
        {
          "_type": "span",
          "_key": "span1",
          "text": "Your text content here",
          "marks": []
        }
      ],
      "markDefs": []
    }
  ]
}

The key components:

  • _type: "block": Identifies this as a text block
  • style: Defines the block style (normal, h1, h2, blockquote, etc.)
  • children: Array of text spans
  • markDefs: Defines complex annotations like links

The _key Requirement

This is critical: every object in an array requires a _key property. When you create content in Sanity Studio, these are auto-generated, but when importing via CLI, you must provide them yourself.

The _key must be unique within its parent array (not globally unique like _id). Use a UUID library to generate them:

import {uuid} from '@sanity/uuid'

const block = {
  _key: uuid(),
  _type: "block",
  style: "normal",
  children: [
    {
      _key: uuid(),
      _type: "span",
      text: "Hello world",
      marks: []
    }
  ],
  markDefs: []
}

Here's a more complex example showing bold text and a link:

{
  "_type": "block",
  "_key": "block1",
  "style": "normal",
  "children": [
    {
      "_type": "span",
      "_key": "span1",
      "text": "This is ",
      "marks": []
    },
    {
      "_type": "span",
      "_key": "span2",
      "text": "bold text",
      "marks": ["strong"]
    },
    {
      "_type": "span",
      "_key": "span3",
      "text": " and ",
      "marks": []
    },
    {
      "_type": "span",
      "_key": "span4",
      "text": "a link",
      "marks": ["link1"]
    }
  ],
  "markDefs": [
    {
      "_key": "link1",
      "_type": "link",
      "href": "https://example.com"
    }
  ]
}

Notice how:

  • Simple decorators like "strong" go directly in the marks array
  • Complex annotations like links are referenced by their _key in marks, with the full definition in markDefs

Converting HTML to Portable Text

If you're migrating from HTML-based content, don't manually construct this JSON! Use the @portabletext/block-tools package:

npm install @portabletext/block-tools jsdom

Then use the htmlToBlocks function:

import {htmlToBlocks} from '@portabletext/block-tools'
import {JSDOM} from 'jsdom'
import {Schema} from '@sanity/schema'

const blocks = htmlToBlocks(
  '<p>Your HTML here</p>',
  blockContentSchema,
  {
    parseHtml: (html) => new JSDOM(html).window.document
  }
)

This will handle the conversion and generate all the required _key values for you.

Pro Tip: Use the Inspect Button

The fastest way to see the exact JSON structure Sanity expects:

  1. Create a sample document in Sanity Studio with your desired Portable Text content
  2. Click the Inspect button (in the document menu, looks like <>)
  3. Copy the JSON structure as your template

This shows you exactly what your import data should look like, including all the _key values and nested structure.

CLI Import Format

When using sanity dataset import, your NDJSON file should have one document per line:

{"_id":"post-1","_type":"post","content":[{"_type":"block","_key":"abc","children":[{"_type":"span","_key":"def","text":"Hello","marks":[]}],"markDefs":[]}]}
{"_id":"post-2","_type":"post","content":[{"_type":"block","_key":"ghi","children":[{"_type":"span","_key":"jkl","text":"World","marks":[]}],"markDefs":[]}]}

Then import with:

sanity dataset import data.ndjson production

You're absolutely right that this is a common challenge when getting data into Sanity—the content migration documentation covers these patterns in depth for more complex scenarios.

This is the closest thing I could think of:
Deserialization and the
relevant GitHub -- it's not a direct correlation but since there are likely analogues in your objects, it could be a useful middle ground.
This was my shot at just trying to surf the errors to get any text in at all.

  "excerpt": {
    "_type": "block",
    "children": [
      {
        "_type": "span",
        "text": "My text here."
      }
    ]
  },
But I wind up with
TypeError: val is not iterable
after the import runs successfully.
I’m still missing the layer of my PortableText object type, but it’s unclear what property the value is stored in.
It should be something like…


  "excerpt": {
    "_type": "strictPortableText",
    "blocks": [
      {
        "_type": "block",
        "children": [
          {
            "_type": "span",
            "text": "Learn how to deliver impactful DevRel campaigns that drive results and impress stakeholders."
          }
        ]
      }
    ]
  }
Just to be clear, you have outside existing JSON, and you want to run that through a process to become portable text, so you can form a document using that result as the portable text for that document?
If I am underestanding correctly, the result should be an array that looks like this example:
https://www.sanity.io/guides/introduction-to-portable-text#24a5ca89c31c but the array itself would be placed inside a label corresponding to the field you've chosen to be the block editor / array of blocks:
(reproducing from a little lower from that same link because there's no direct link)


{
  "body": [
    {
      "_type": "block",
      "style": "normal",
      "children": [
        {
          "_type": "span",
          "marks": [],
          "text":  "You can visit us in our opening hours at this location:"
        }
      ],
      "markDefs": []
    },
    {
      "_type": "location",
      "coordinates": {
        "_type": "geopoint",
        "lat": 59.924010,
        "long": 10.758880
      }
    }
  ]
}
Sit tight for a sec and I will get an example from one of my actual documents so you can see it in context.
It seems like I have to name the portable text type or the admin UI complains. Something like…

  "excerpt": {
    "_type": "strictPortableText",
    "children": [
      {
        "_type": "block",
        "children": [
          {
            "_type": "span",
            "text": "Learn how to deliver impactful DevRel campaigns that drive results and impress stakeholders."
          }
        ]
      }
    ]
  },
Here's one of my documents that has two Portable Text fields -- note the first, additionalInformation, starting on line 7 and the second, placeBody on line 46 (apologies for lack of color highlighting)
This did the trick…
  "excerpt": [
    {
      "_type": "block",
      "style": "normal",
      "children": [
        {
          "_type": "span",
          "text": "Learn how to deliver impactful DevRel campaigns that drive results and impress stakeholders.",
          "marks": []
        }
      ],
      "markDefs": []
    }
  ],
Great! Yeah it's kind of like a reverse math problem to check the answer. You might not use the Studio at all but if it has to run the doc against a schema to interpret it to view, it needs something to hang it on like that.
BTW: This is the wrong/misleading help text…
OMG, I just found the “INSPECT” button!
I am just a random person and don't work for Sanity so I can't really account for anything except what I've experienced and I've never done an import like you're talking about. I haven't even programmatically created a document yet.
Oh yeah haha Inspect is what I used to make the link to my doc I sent a few mins ago
🤯
Thank you random Vincent!
haha I am not sure of the process, specific Josh, but I noticed in the resulting format and what you had earlier, the individual blocks in the array you had were missing keys. The GitHub tool I linked earlier might be generating those.

Notice that this example includes a _key. This is included to make it easier to use Portable Text in real-time interfaces. However, we will omit it in the following code examples for readability.
I think it's particularly so that if you're collaborating people can't be working on the same parts of the block editor and overwriting each other.
Gotcha. I assumed Sanity would autogenerate a
_key
on the way in during the import. I’d better confirm.
It's definitely a good question, I just don't know the answer outright.

Sanity – Build the way you think, not the way your CMS thinks

Sanity is the developer-first content operating system that gives you complete control. Schema-as-code, GROQ queries, and real-time APIs mean no more workarounds or waiting for deployments. Free to start, scale as you grow.

Was this answer helpful?