Frontend

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.

tsx
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:

ts
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.