What It Is
PortableText is a specification for storing rich text content as structured JSON, developed by Sanity to solve the problem of vendor lock-in and content portability across different rendering platforms. Unlike HTML or proprietary rich text formats, PortableText represents content as an array of block objects, each containing typed elements like paragraphs, headings, lists, and custom components, with inline formatting applied through marks and annotations. This approach separates content structure from presentation logic, making it platform-agnostic and highly suitable for headless content management architectures where the same content needs to render consistently across web portals, mobile applications, and ServiceNow interfaces.
Within ServiceNow's architecture, PortableText integration typically lives in the presentation layer through custom UI components or Service Portal widgets that consume content from external Sanity CMS instances. The integration points exist primarily in Service Portal widgets, UI Pages, or custom Angular/React components loaded through UI Scripts and Style Sheets. The actual content data flows through REST API integrations managed via REST Message records or Script Includes that fetch PortableText JSON from Sanity's CDN endpoints, then pass this structured data to rendering components that understand the PortableText specification.
The underlying data model treats PortableText as JSON strings stored in custom fields on tables like kb_knowledge, sc_cat_item, or custom content tables, though more commonly it exists as transient data fetched during page rendering rather than persisted within ServiceNow's database. The execution environment relies heavily on the ServiceNow platform's ability to load external JavaScript libraries like @portabletext/react through CDN references or bundled assets uploaded as UI Scripts. This creates a dependency on ServiceNow's Content Security Policy settings and requires careful management of script loading order to ensure PortableText rendering libraries are available before content components attempt to render.
You cannot function without PortableText understanding when your organization has adopted Sanity CMS as a headless content management solution for customer-facing portals, knowledge bases, or service catalogs that need to display rich content with consistent formatting across multiple channels. Enterprise implementations often require content authors to work in specialized CMS tools while having that same content render perfectly within ServiceNow's Service Portal for customer self-service experiences. Without proper PortableText handling, you're stuck with either copying content manually between systems (creating maintenance nightmares) or accepting broken formatting when content gets pulled from APIs, resulting in poor user experiences and content that looks unprofessional in your ServiceNow interfaces.
Platform developers typically own PortableText integration implementation, writing the Script Includes and UI components that fetch and render content, while application developers configure specific content mappings and customize rendering for business requirements. ServiceNow administrators manage the operational aspects like REST Message credentials, CSP policy adjustments, and monitoring integration health through System Logs and REST Message Logs. Content authors work entirely within Sanity Studio and don't directly interact with ServiceNow's PortableText rendering, but they need to understand how their content structure decisions affect the final display in ServiceNow interfaces. The content flow creates a clear separation of concerns where CMS users focus on authoring, developers handle technical integration, and admins ensure system reliability and performance.
Recent ServiceNow releases have improved support for modern JavaScript frameworks and CDN-loaded libraries, making PortableText integration more reliable in Vancouver and later versions through enhanced CSP management and better Service Portal performance. The Next Experience UI Framework in Xanadu provides better React component support, which aligns well with @portabletext/react rendering needs, though most implementations still occur in classic Service Portal contexts. Vancouver also introduced improved REST API timeout handling and better error logging for external integrations, making Sanity CMS connections more robust and easier to troubleshoot when content fetching fails.
Where to Find and Configure It
Navigate to System Web Services > Outbound > REST Message to configure your primary integration with Sanity's API endpoints where you define the authentication, headers, and endpoint URLs for fetching PortableText content. Configure Service Portal > Widgets to create custom widgets that consume and render PortableText content using your REST Message configurations. Access System UI > UI Scripts to upload or reference the @portabletext/react library and related dependencies needed for client-side rendering.
Secondary configuration exists in System Definition > Script Includes where you build server-side utilities for fetching, caching, and preprocessing PortableText content before sending it to client components. Use System Security > Content Security Policy to whitelist Sanity's CDN domains and any external JavaScript libraries required for PortableText rendering. Check System Properties > Security for CORS and external request settings that affect API calls to Sanity endpoints.
See PortableText in action by viewing Service Portal > Portal Page Designer when testing widgets that render content, or examine System Logs > System Log > All to monitor API calls and rendering errors. Monitor actual content delivery through System Web Services > REST Message Logs to see the raw PortableText JSON being fetched from Sanity and troubleshoot integration issues. View rendered output in your live Service Portal pages or through Service Portal > Designer preview mode to validate formatting and styling.
Scoped applications handle PortableText integration through application-specific Script Includes and widgets, keeping integration logic isolated within the application boundary while still requiring global CSP and REST Message configurations for external API access. Global applications can define reusable PortableText utilities that other applications consume, but this creates cross-application dependencies that complicate update management and requires careful consideration of which integration components should remain global versus application-specific.
How It Works Step by Step
PortableText rendering in ServiceNow follows a client-server pattern where server-side Script Includes fetch structured content from Sanity's API, then client-side JavaScript components parse the PortableText JSON and render it as HTML elements with appropriate styling. The server component handles authentication, caching, and content preprocessing, while the client component manages the actual DOM rendering using libraries like @portabletext/react or custom JavaScript parsers that interpret PortableText blocks and marks. This separation ensures that content fetching doesn't block page rendering while maintaining security through server-side credential management.
The integration relies on ServiceNow's REST integration capabilities to communicate with Sanity's CDN, where content is stored as PortableText JSON and accessed through query parameters that specify document IDs or content types. Server-side processing often includes content transformation, caching in custom tables or system cache to reduce API calls, and error handling for scenarios where external content is unavailable. Client-side rendering iterates through PortableText block arrays, mapping each block type to specific HTML elements while applying inline marks and handling custom components or annotations that require special rendering logic.
Enjoying this? Get one deep-dive per week.
Join 1,000+ ServiceNow pros — scripts, GlideRecord patterns, Flow Designer techniques, and career moves. Free.
The Execution Order
- Service Portal widget server script executes, calling a Script Include to fetch content from Sanity using a configured REST Message with project ID and content query parameters.
- Script Include processes the REST response, validates the PortableText JSON structure, and optionally caches the content in a custom table or system cache to improve performance.
- Server script passes the PortableText JSON to the client controller through
data.contentor similar variable, making it available for client-side processing. - Page loads and ServiceNow renders the widget HTML template, including script tags that load the PortableText rendering library from UI Scripts or external CDN.
- Client controller initializes and calls the PortableText renderer, passing the content JSON and custom component mappings for handling different block types.
- PortableText library iterates through blocks, renders each as HTML elements, applies inline marks like bold or italic, and processes annotations like links or custom components.
- Final HTML gets inserted into the DOM at the target container element, with CSS from UI Style Sheets applying visual formatting to match your portal design.
var SanityPortableTextUtil = Class.create();
SanityPortableTextUtil.prototype = {
initialize: function() {
this.projectId = gs.getProperty('sanity.project.id');
this.dataset = gs.getProperty('sanity.dataset', 'production');
this.apiVersion = gs.getProperty('sanity.api.version', '2023-05-03');
},
fetchContent: function(documentId) {
try {
var rm = new sn_ws.RESTMessageV2('Sanity Content API', 'GET');
rm.setStringParameterNoEscape('project_id', this.projectId);
rm.setStringParameterNoEscape('dataset', this.dataset);
rm.setStringParameterNoEscape('api_version', this.apiVersion);
rm.setStringParameterNoEscape('document_id', documentId);
var response = rm.execute();
if (response.getStatusCode() == 200) {
var content = JSON.parse(response.getBody());
return content.result;
}
gs.error('Sanity API error: ' + response.getStatusCode());
return null;
} catch (e) {
gs.error('Failed to fetch Sanity content: ' + e.message);
return null;
}
},
type: 'SanityPortableTextUtil'
};Real-World Scenarios
Dynamic Knowledge Base Articles with Rich Content
Your content team needs to author complex knowledge articles with embedded videos, code blocks, and interactive elements in Sanity Studio while having those articles display perfectly in ServiceNow's customer portal. The articles must maintain formatting consistency and support content updates without requiring ServiceNow deployments.
// Server Script
function() {
var util = new SanityPortableTextUtil();
var articleSlug = $sp.getParameter('article');
if (articleSlug) {
var query = '*[_type == "article" && slug.current == "' + articleSlug + '"][0]';
data.article = util.fetchContentByQuery(query);
data.hasContent = !!data.article;
}
}
// Client Controller
function($scope, $sce) {
var c = this;
c.$onInit = function() {
if (c.data.hasContent && window.PortableTextReact) {
c.renderPortableText();
}
};
c.renderPortableText = function() {
var components = {
types: {
code: function(props) {
return '<pre><code class="language-' + props.value.language + '">' +
props.value.code + '</code></pre>';
}
}
};
var container = document.getElementById('article-content');
PortableTextReact.render(c.data.article.body, container, components);
};
}Watch for CSP violations when loading syntax highlighting libraries for code blocks, and ensure your custom component mappings handle all block types used in Sanity to prevent rendering errors. Cache article content aggressively since knowledge base content changes infrequently, but implement cache invalidation webhooks from Sanity to update content immediately when authors publish changes.
Service Catalog Items with Marketing Content
Marketing wants to create rich product descriptions with images, bullet points, and call-to-action buttons for service catalog items, but they need to work in their preferred CMS while having that content appear in ServiceNow's service catalog. The content must support A/B testing and personalization based on user attributes.
// Script Include for catalog integration
fetchCatalogContent: function(itemSysId) {
var catalogItem = new GlideRecord('sc_cat_item');
if (catalogItem.get(itemSysId)) {
var contentKey = catalogItem.getValue('u_content_key');
if (contentKey) {
var query = '*[_type == "catalogContent" && key == "' + contentKey + '"][0]';
var content = this.fetchContentByQuery(query);
if (content && content.variants) {
// Simple A/B testing logic
var userGroup = this.getUserGroup();
return content.variants[userGroup] || content.variants.default;
}
return content;
}
}
return null;
},
getUserGroup: function() {
var user = gs.getUser();
return user.hasRole('vip_customer') ? 'vip' : 'standard';
}Link catalog items to Sanity content through custom u_content_key fields and ensure your PortableText rendering handles image optimization for different screen sizes. Consider caching personalized content per user group rather than per individual user to balance performance with customization, and implement fallback content when Sanity API calls fail to prevent broken catalog pages.
Multi-language Portal Content Management
Your global organization needs portal content in multiple languages with the ability for regional teams to manage translations independently while maintaining consistent structure and branding. Content must automatically display in the user's preferred language with graceful fallback to default language when translations are unavailable.
// Enhanced content fetching with language support
fetchLocalizedContent: function(contentType, contentId, userLanguage) {
userLanguage = userLanguage || gs.getSession().getLanguage();
var defaultLang = gs.getProperty('portal.default.language', 'en');
// Try user's language first
var query = '*[_type == "' + contentType + '" && contentId == "' + contentId +
'" && language == "' + userLanguage + '"][0]';
var content = this.fetchContentByQuery(query);
// Fallback to default language
if (!content && userLanguage !== defaultLang) {
query = '*[_type == "' + contentType + '" && contentId == "' + contentId +
'" && language == "' + defaultLang + '"][0]';
content = this.fetchContentByQuery(query);
}
return content;
},
getUserLanguage: function() {
var user = gs.getUser();
var userLang = user.getPreference('user.language');
return userLang || gs.getSession().getLanguage();
}Sanity's internationalization features require careful query structure to avoid fetching all language variants unnecessarily, which can impact performance and API costs with large content volumes.
Structure your Sanity schema to include language fields in document metadata and implement client-side language switching that updates content without full page reloads. Monitor translation completeness through Sanity queries that identify missing language variants, and consider implementing automated notifications to content teams when new content needs translation across all supported languages.
The Classic Mistake
Rendering PortableText blocks without proper key validation causes React hydration mismatches and corrupted component state.
// BAD: Missing key validation and improper component mapping
function BadPortableTextRenderer({ content }) {
const components = {
block: ({ children, value }) => {
if (value.style === 'h2') return <h2>{children}</h2>
return <p>{children}</p>
},
marks: {
link: ({ children, value }) => (
<a href={value.href}>{children}</a>
)
}
}
return <PortableText value={content} components={components} />
}This fails because React cannot reconcile component trees when _key properties are missing or duplicated across PortableText blocks. Users see content flickering, form inputs losing focus, and inconsistent rendering between server and client. React's virtual DOM diffing algorithm requires stable keys to track component instances across renders, but PortableText blocks without proper key validation create phantom DOM nodes that persist in memory. The symptom appears as hydration warnings in the browser console and visual content jumping during page load.
// GOOD: Proper key validation and comprehensive component mapping
function GoodPortableTextRenderer({ content }) {
const components = {
block: ({ children, value }) => {
const key = value._key || `block-${Math.random()}`
if (value.style === 'h2') return <h2 key={key}>{children}</h2>
if (value.style === 'h3') return <h3 key={key}>{children}</h3>
return <p key={key}>{children}</p>
},
marks: {
link: ({ children, value, markKey }) => (
<a key={markKey} href={value.href} target={value.blank ? '_blank' : '_self'}>
{children}
</a>
)
},
types: {
codeBlock: ({ value }) => (
<pre key={value._key}>
<code className={`language-${value.language}`}>
{value.code}
</code>
</pre>
)
}
}
return <PortableText value={content} components={components} />
}Always validate that every PortableText block has a unique _key property and map all custom block types explicitly in the components object.
When to Use This vs Alternatives
PortableText is the right choice when you need structured rich text that separates content from presentation and supports complex inline annotations. Use it for article bodies, product descriptions, and any content that requires semantic markup with custom rendering logic.
Choose PortableText Over HTML
PortableText beats raw HTML when you need content portability across different frontend frameworks or mobile apps. HTML strings lock you into DOM-specific rendering and make content analysis impossible. PortableText's structured format allows you to extract headings for table of contents generation, count words accurately, and transform the same content into different output formats like PDF or email templates.
Use Markdown Instead
Choose Markdown over PortableText for developer-focused content like documentation or README files where writers prefer plain text syntax. Markdown excels when content creators are technical users who want version control-friendly formats. PortableText requires a visual editor and JSON storage, making it overkill for simple formatted text that doesn't need custom block types or complex annotations.
Combine with Block-Level CMS
Use PortableText alongside page builders like Sanity's block content when you need both structured rich text and layout flexibility. PortableText handles inline formatting and text-heavy content while block-level schemas manage images, videos, and complex component arrangements. This combination gives content creators a visual editor for rich text within larger page layouts without sacrificing the structured benefits of JSON-based content.
Platform Interactions & Side Effects
- React's
useEffecthooks fire on every PortableText content change, potentially triggering expensive re-renders if component dependencies aren't memoized properly - Custom serializers bypass React's built-in XSS protection, allowing malicious content injection if
dangerouslySetInnerHTMLis used in component definitions - Server-side rendering breaks when PortableText components reference
windowordocumentobjects directly without proper guards - Bundle size increases significantly when importing entire icon libraries or component sets within PortableText serializers instead of using dynamic imports
- Next.js image optimization fails for images embedded in PortableText blocks unless explicitly wrapped with
next/imagecomponents in custom serializers - Memory leaks occur when event listeners attached in PortableText component mount cycles aren't properly cleaned up in
useEffectreturn functions - SEO crawlers miss content when PortableText renders complex interactive elements that require JavaScript execution to display text content
- Content Security Policy violations trigger when PortableText serializers dynamically generate inline styles or load external resources without proper
nonceattributes - React DevTools performance profiler shows massive component trees when PortableText blocks contain deeply nested mark definitions with multiple annotation layers
- TypeScript compilation fails when PortableText component props don't match the exact schema definition, requiring explicit type assertions or interface extensions
Debugging and Troubleshooting
The most common failure symptom is content rendering as plain JSON objects instead of formatted HTML, appearing as [object Object] text on the frontend. Users see hydration mismatches manifesting as content flickering between server-rendered and client-rendered states. Administrators encounter React error boundaries triggering with messages about missing component definitions or invalid block types.
Check the browser's Developer Tools Console for specific error messages like Unknown block type or Missing serializer for mark type. React's Profiler tab reveals performance bottlenecks in PortableText rendering, while the Components tab shows the actual component tree structure and prop values. Enable React's Strict Mode in development to catch double-rendering issues and component lifecycle problems early.
Network tab inspection reveals whether PortableText content is being fetched correctly from the CMS API, while server logs show serialization errors during the initial render. Look for specific error patterns like Cannot read property '_key' of undefined indicating malformed block structures. The React Error Boundary component should log complete stack traces showing exactly which PortableText block or mark type caused the failure.
Diagnostic Checklist:
- Verify all PortableText blocks have unique
_keyproperties by logging the content structure to console - Check that all custom block types and mark definitions have corresponding serializers in the components object
- Test rendering with minimal components first, then add complexity incrementally to isolate failures
- Validate JSON structure against PortableText schema using tools like
@portabletext/toolkit - Enable React's development mode warnings and fix all hydration mismatches before deploying
- Compare server-rendered HTML with client-rendered output to identify component serialization differences
- Profile component render times using React DevTools to identify performance bottlenecks in complex content structures
Quick Reference
- Maximum block depth is 100 levels before PortableText rendering performance degrades exponentially and stack overflow errors occur
- The
markDefsarray must contain definitions for ALL marks referenced in spans, or React throws undefined reference errors - Custom serializers receive different prop structures for blocks (
children, value) vs marks (children, value, markType) - Empty spans with
text: ""still render as DOM nodes and affect layout calculations, especially in flexbox containers - List items with
levelproperties higher than 6 break semantic HTML structure and accessibility screen readers - Block-level custom types bypass normal text flow and require explicit width/height styles to prevent layout collapse
- The
@portabletext/reactpackage adds ~15KB to bundle size plus any custom component dependencies - Nested marks like
stronginsideemrequire array-based mark definitions:["em", "strong"] - Server-side rendering fails silently when custom components access browser APIs, falling back to client-only rendering
- Content changes in Sanity Studio take up to 30 seconds to propagate through CDN caching layers to frontend applications