Architecture Note: Using Sanity Portable Text as a Rich‑Text Field
Guide to defining, validating, and operating Portable Text in Sanity, covering requirements, minimal schema, trust boundaries, checks, failure modes and when to redesign.
20 Feb 2026, 00:00 UTC

Requirements
\nEditors need a WYSIWYG rich‑text field that can be rendered to web, mobile and print outputs. The content must stay portable, be versioned inside the Content Lake, and be queryable with GROQ. The schema should stay simple, and validation must happen at write time to prevent malformed data from entering the lake.
\nSmallest Suitable Design
\nDefine a field of kind array whose of contains a single block type. This mirrors Portable Text’s canonical JSON shape and needs no custom types for basic rich text.
// schemas/post.js\nexport default {\n name: 'post',\n title: 'Post',\n type: 'document',\n fields: [\n {\n name: 'body',\n title: 'Body',\n type: 'array',\n of: [{ type: 'block' }],\n },\n ],\n};\n\nEach block JSON object includes optional style, list, marks (annotations) and children (inline spans or nested blocks). This is the minimal design that satisfies the requirements.
Trust / Data Boundaries
\nSanity Studio validates incoming mutations against the declared schema before they are sent to the Content Lake. The Lake re‑validates the JSON blob on ingest and rejects any document that deviates from the declared block structure with a 400 response. API consumers therefore receive data that has already been vetted by the server‑side enforcement; trust is placed in the Content Lake, not the client.
\nOperational Checks
\n- \n
- CI schema validation – Run
sanity schema checkin your CI pipeline (requires the Sanity CLI installed and a token withschema:readscope). This catches mismatches between the local schema files and the remote dataset. \n - Preview mode – Enable Studio’s preview pane to render Portable Text using your project’s serializers; this surfaces missing serializer issues early. \n
- Webhook lint – Configure a publish webhook that receives the document, runs a lint step (e.g., checks for unknown marks or missing
_typeon blocks), and alerts on failure. \n - Monitor API errors – Track 400 validation errors from the Content Lake during write operations; a sudden increase indicates a schema or client issue. \n
Example: CI Schema Check Command
\nRun in the repository root:
\n# Requires: Sanity CLI v3+, SANITY_STUDIO_TOKEN with schema:read\nsanity schema check --dataset production\n\nExpected check: the command exits with code 0 if the local schema matches the remote schema; non‑zero output lists mismatches. Risk: if the token lacks sufficient permissions the command will fail with an authentication error, not a schema mismatch.
\nFailure Modes and Design‑Change Triggers
\n- \n
- Malformed Portable Text – Sending a block without
_typeor with an unknown mark results in a 400 validation error from the Content Lake, preventing the mutation from being applied. \n - Missing serializer – A custom mark defined in the schema but lacking a corresponding frontend serializer will be silently dropped in the rendered output, leading to content loss. \n
- Document size limit** – The Content Lake imposes a 16 KB limit per document. Large inline images or excessive nesting can cause the limit to be exceeded, forcing a split into multiple blocks or moving binary data to asset references. \n
- Real‑time collaborative editing beyond block‑level – If multiple users need to edit the same inline span concurrently, the block‑level granularity may become insufficient. \n
- Complex block types – Requirements for embeddings, tables or custom widgets with their own schema fields exceed the generic
blockmodel. \n
When any of the above conditions arise, the design should evolve:
\n- \n
- Introduce explicit block schemas (e.g.,
{type: 'image'},{type: 'table'}) inside theofarray. \n - Consider a separate document type for large assets or embeddings, referencing them by ID from Portable Text. \n
- Adopt a custom block type with its own fields when inline collaboration on complex objects is required. \n
Practical Verification Steps
\n- \n
- Create a new Sanity project, add the schema field shown above, start the studio, enter rich text with styles, lists and a custom mark, then publish. \n
- Query the document via GROQ:
*[_type == 'post']{body}. Verify the returned JSON matches the Portable Text spec (each object has_type: 'block', optionalstyle,list,marksarray, andchildren). \n - Attempt to insert an object missing
_typeor containing an unknown mark via the API (e.g., usingcurlwith a mutation). Observe a 400 response with details about the invalid block, confirming server‑side enforcement. \n - Render the fetched Portable Text with
@sanity/block-content-to-reactusing a minimal serializer set that includes handlers for all marks and styles present. Compare the output to the studio preview; mismatches indicate missing serializers. \n - In CI, add a test step that posts a known‑bad Portable Text payload and asserts the request fails (non‑2xx status). This demonstrates the validation gate works in automated pipelines. \n
Limitations
\nPortable Text is optimized for block‑level rich text. Storing large binary data directly inside blocks (e.g., base64 images) will quickly hit the 16 KB document limit and bypass Sanity’s asset management. Use the built‑in image block type, which stores only an asset reference, to stay within limits and gain proper asset handling. Additionally, any custom mark or block type must be mirrored in frontend serializers; otherwise the data persists in the lake but is not rendered, leading to silent loss.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.