Rendering Portable Text and images
Server-render with @portabletext/react, pass your own schema types in as components, and classify every href before you emit an anchor.
import { ContentPortableText } from '@neworange/content-next/site'
export function Body({ value, location }) {
return (
<ContentPortableText
value={value}
location={location}
components={{
types: {
callout: ({ value }) => <aside data-tone={value.tone}>{value.text}</aside>,
},
}}
/>
)
}The platform supplies what is true of any schema — blocks, lists, marks, images. Your own embedded object types are passed in, because a CMS package cannot know your content model.
Two rules
Never drop an unknown type. Render it visibly instead. A missing callout looks exactly like an editor who did not write one, and that failure survives review indefinitely.
Never trust an href. Studio validation does not gate the API, so a stored href can be anything:
function classifyHref(href: string) {
if (href.startsWith('//')) return 'unsupported' // origin change in relative clothing
if (href.startsWith('/')) return 'relative'
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(href)?.[1]?.toLowerCase()
if (scheme === 'mailto') return 'mailto'
if (scheme === 'http' || scheme === 'https') return 'external'
return 'unsupported'
}Anything unsupported renders as text with no anchor. javascript:, data: and vbscript: are the reason this is an allow-list rather than a blocklist.
Images
Use a plain responsive image with a srcset built from the width ladder. Do not also wrap it in a framework image component: the asset route already redirects into image optimisation, so wrapping it bills two transformations per image and resizes an already-resized source.
Alt text is required by the example schema on every image field, and a renderer should treat a missing one as decorative rather than announcing an empty string as content.
