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 →How I Shaved 140 kB Off a Next.js Bundle by Lazy-Loading Sanity Portable Text
Apr 27, 2026 · 5 min read
Portable Text blocks can bloat client bundles. Here's how I defer serializers with dynamic imports and RSC boundaries to keep marketing pages under 80 kB.
How I set up Sanity TypeGen for fully typed GROQ queries in TypeScript
May 13, 2026 · 6 min read
A step-by-step guide to Sanity TypeGen: CLI config, generating TypeScript types from GROQ queries, CI integration, and handling schema evolution gotchas.
How I reduced Sanity webhook fanout by batching revalidation tags in a single route handler
May 09, 2026 · 5 min read
A pattern for consolidating dozens of per-document Next.js revalidations into one tagged endpoint, cutting webhook noise and deploy times by 70%.