How I build Sanity Portable Text custom components in Next.js

May 16, 2026 · 6 min read

Sanity Portable Text custom components in Next.js are straightforward once you understand the serializer API — but the documentation skips several patterns you'll hit on real projects. This post covers four component types I use on almost every client site: syntax-highlighted code blocks, callout boxes, embedded lead-capture forms, and images with captions.

What the component API actually looks like

@portabletext/react accepts a components prop that maps block types, marks, list items, and custom types to React components. The library ships TypeScript generics so every component receives a typed value prop — you just need to tell it the shape of your custom fields.

Install the package if you haven't already:

// package.json (relevant deps)
"@portabletext/react": "^3.1.0",
"@sanity/image-url": "^1.0.2"

The top-level wiring lives in a single file I keep at components/portable-text/index.tsx:

// components/portable-text/index.tsx
import { PortableText, PortableTextComponents } from '@portabletext/react'
import { CodeBlock } from './code-block'
import { Callout } from './callout'
import { EmbeddedForm } from './embedded-form'
import { ImageWithCaption } from './image-with-caption'
import type { PortableTextBlock } from '@portabletext/types'
 
const components: PortableTextComponents = {
  types: {
    code: CodeBlock,
    callout: Callout,
    embeddedForm: EmbeddedForm,
    imageWithCaption: ImageWithCaption,
  },
  marks: {
    internalLink: ({ value, children }) => (
      <a href={`/${value?.slug?.current}`} className="underline text-blue-600">
        {children}
      </a>
    ),
  },
  block: {
    h2: ({ children }) => (
      <h2 className="text-2xl font-semibold mt-10 mb-4">{children}</h2>
    ),
  },
}
 
export function RichText({ value }: { value: PortableTextBlock[] }) {
  return <PortableText value={value} components={components} />
}

Each key under types matches the _type field Sanity stores in the block array. If your schema uses name: 'callout', the key must be callout. That's the most common mismatch I see.

Code block with syntax highlighting

Sanity's built-in code plugin stores language and code strings. I render it with react-syntax-highlighter using the oneDark theme, but I import only the light-build to avoid pulling in all 200 languages.

// components/portable-text/code-block.tsx
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter/dist/esm/prism-light'
import oneDark from 'react-syntax-highlighter/dist/esm/styles/prism/one-dark'
import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx'
import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash'
import groq from 'react-syntax-highlighter/dist/esm/languages/prism/groq'
import type { PortableTextTypeComponentProps } from '@portabletext/react'
 
SyntaxHighlighter.registerLanguage('tsx', tsx)
SyntaxHighlighter.registerLanguage('bash', bash)
SyntaxHighlighter.registerLanguage('groq', groq)
 
type CodeValue = {
  _type: 'code'
  language: string
  code: string
  filename?: string
}
 
export function CodeBlock({ value }: PortableTextTypeComponentProps<CodeValue>) {
  return (
    <figure className="my-6">
      {value.filename && (
        <figcaption className="text-xs text-zinc-400 bg-zinc-800 px-4 py-1 rounded-t font-mono">
          {value.filename}
        </figcaption>
      )}
      <SyntaxHighlighter
        language={value.language ?? 'text'}
        style={oneDark}
        customStyle={{ margin: 0, borderRadius: value.filename ? '0 0 6px 6px' : '6px' }}
      >
        {value.code}
      </SyntaxHighlighter>
    </figure>
  )
}

The PortableTextTypeComponentProps<T> generic is doing the heavy lifting. Supply your own value type and TypeScript will catch any field access mistakes.

Callout box

My Sanity schema for a callout has a tone field (info | warning | danger) and a nested content portable text array. The nested array renders with a second PortableText call — no new component map needed if the callout only contains inline text.

// components/portable-text/callout.tsx
import { PortableText } from '@portabletext/react'
import type { PortableTextTypeComponentProps } from '@portabletext/react'
import type { PortableTextBlock } from '@portabletext/types'
 
type CalloutValue = {
  _type: 'callout'
  tone: 'info' | 'warning' | 'danger'
  content: PortableTextBlock[]
}
 
const toneClasses: Record<CalloutValue['tone'], string> = {
  info: 'border-blue-500 bg-blue-50 text-blue-900',
  warning: 'border-amber-500 bg-amber-50 text-amber-900',
  danger: 'border-red-500 bg-red-50 text-red-900',
}
 
export function Callout({ value }: PortableTextTypeComponentProps<CalloutValue>) {
  return (
    <aside className={`border-l-4 px-5 py-4 my-6 rounded-r ${toneClasses[value.tone]}`}>
      <PortableText value={value.content} />
    </aside>
  )
}

If your callout's content needs the same custom marks (internal links, etc.), pass the same components map into the nested PortableText. I usually extract the components object to a shared module for exactly that reason.

Image with caption

Sanity stores hotspot images differently from plain image fields. You need @sanity/image-url to resolve the CDN URL, and you should pass width/height to Next.js Image to avoid CLS. I calculate a fixed aspect ratio from the image asset metadata rather than hardcoding pixel dimensions.

// components/portable-text/image-with-caption.tsx
import NextImage from 'next/image'
import { urlFor } from '@/lib/sanity/image' // your imageUrlBuilder wrapper
import type { PortableTextTypeComponentProps } from '@portabletext/react'
import type { SanityImageObject } from '@sanity/image-url/lib/types/types'
 
type ImageWithCaptionValue = {
  _type: 'imageWithCaption'
  asset: SanityImageObject
  caption?: string
  alt?: string
}
 
export function ImageWithCaption({
  value,
}: PortableTextTypeComponentProps<ImageWithCaptionValue>) {
  const src = urlFor(value).width(1200).auto('format').url()
  return (
    <figure className="my-8">
      <NextImage
        src={src}
        alt={value.alt ?? ''}
        width={1200}
        height={675}
        className="rounded-lg w-full h-auto"
        sizes="(max-width: 768px) 100vw, 860px"
      />
      {value.caption && (
        <figcaption className="text-sm text-zinc-500 text-center mt-2">
          {value.caption}
        </figcaption>
      )}
    </figure>
  )
}

The sizes attribute matters here — without it, Next.js Image generates a 1200 px source even on mobile and you waste bandwidth.

Embedded form

Forms in portable text are always client components — they need useState for field values and a submit handler. The parent RichText component can stay a server component as long as the form component is marked 'use client'.

My EmbeddedForm type stores a formId string I map to actual form configs at runtime. That way editors don't write JSX — they pick a form from a dropdown in the studio, and the component resolves the right fields and API endpoint.

The pattern:

// components/portable-text/embedded-form.tsx
'use client'
import { useState } from 'react'
import type { PortableTextTypeComponentProps } from '@portabletext/react'
 
type EmbeddedFormValue = {
  _type: 'embeddedForm'
  formId: 'newsletter' | 'contact' | 'waitlist'
}
 
const formMeta: Record<EmbeddedFormValue['formId'], { label: string; endpoint: string }> = {
  newsletter: { label: 'Subscribe', endpoint: '/api/subscribe' },
  contact: { label: 'Send message', endpoint: '/api/contact' },
  waitlist: { label: 'Join waitlist', endpoint: '/api/waitlist' },
}
 
export function EmbeddedForm({ value }: PortableTextTypeComponentProps<EmbeddedFormValue>) {
  const [email, setEmail] = useState('')
  const [status, setStatus] = useState<'idle' | 'loading' | 'done'>('idle')
  const meta = formMeta[value.formId]
 
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    setStatus('loading')
    await fetch(meta.endpoint, {
      method: 'POST',
      body: JSON.stringify({ email }),
      headers: { 'Content-Type': 'application/json' },
    })
    setStatus('done')
  }
 
  if (status === 'done') return <p className="text-green-700 my-6">Done — check your inbox.</p>
 
  return (
    <form onSubmit={handleSubmit} className="flex gap-3 my-8">
      <input
        type="email"
        required
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="you@example.com"
        className="flex-1 border rounded px-3 py-2 text-sm"
      />
      <button
        type="submit"
        disabled={status === 'loading'}
        className="bg-black text-white px-4 py-2 rounded text-sm disabled:opacity-50"
      >
        {status === 'loading' ? 'Sending…' : meta.label}
      </button>
    </form>
  )
}

A note on unknown types

If a block type exists in Sanity but has no matching key in your components.types map, @portabletext/react renders nothing and logs a warning. Add an unknownType handler during development to catch schema drift early:

// add to your components map
unknownType: ({ value }) => (
  <div className="border border-red-500 p-4 text-sm text-red-700">
    Unknown block type: {value._type}
  </div>
),

Remove it before production or guard it behind process.env.NODE_ENV === 'development'. You don't want editors seeing red boxes on live pages because a schema migration is half-done.

Keeping the component map maintainable

Once you have six or eight custom block types, the single components/portable-text/index.tsx file gets long. I split it into two layers: one file that owns marks and block overrides (headings, blockquote, list styles), and a separate types.ts file that just exports the types map. Both get merged into the final PortableTextComponents object. That way a developer adding a new block type only touches types.ts and creates a sibling component file — they don't need to read the heading or mark code to do it.

Related posts

All posts →