DEV Community

Cover image for Sanity Portable Text Custom Components in Next.js
Nayan Kyada
Nayan Kyada

Posted on Edited on Originally published at nayankyada.com

Sanity Portable Text Custom Components in Next.js

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": "^8.0.0",
"@sanity/image-url": "^2.0.0"
Enter fullscreen mode Exit fullscreen mode

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} />
}
Enter fullscreen mode Exit fullscreen mode

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 official @sanity/code-input plugin stores language and code strings (plus an optional filename). I render it with react-syntax-highlighter using the oneDark theme, but I import only the light build and register the languages I need, so the full Prism language set isn't bundled. Prism has no built-in GROQ grammar, so GROQ snippets fall back to plain text unless you register a custom one.

// components/portable-text/code-block.tsx
import 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 type { PortableTextTypeComponentProps } from '@portabletext/react'

SyntaxHighlighter.registerLanguage('tsx', tsx)
SyntaxHighlighter.registerLanguage('bash', bash)

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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

An image block stores an asset reference (plus hotspot and crop data if hotspot is enabled), not a URL. You need @sanity/image-url to resolve the CDN URL, and you should pass width/height to Next.js Image to avoid CLS. The example below uses a fixed 16:9 box; if your images vary in shape, query the asset's metadata.dimensions and use those instead.

// 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'

type ImageWithCaptionValue = SanityImageObject & {
  _type: 'imageWithCaption'
  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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
),
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)