--- title: "Sanity Portable Text Rules" description: Portable Text (Rich Text) rendering and custom component creation for React/Next.js. --- # Sanity Portable Text Rules Portable Text is Sanity's rich text format, used for content like article bodies (`body[]`). This guide covers rendering and creating custom PTE components. **Note:** For page-level layout blocks (`pageBuilder[]`), see `page-builder.md`. ## 1. The Component Use the `PortableText` component from `next-sanity` (or `@portabletext/react`). ```typescript import { PortableText } from "next-sanity"; // or import { PortableText } from "@portabletext/react"; export function Content({ value }: { value: any }) { return ; } ``` ## 2. Custom Components (`components` prop) **Always** define a typed components object to handle custom blocks, marks, and list styles. ```typescript import { PortableTextComponents } from "next-sanity"; const components: PortableTextComponents = { // 1. Block styles (paragraphs, headings) block: { h1: ({ children }) =>

{children}

, h2: ({ children }) =>

{children}

, blockquote: ({ children }) =>
{children}
, }, // 2. Custom types (non-text blocks like images, videos) types: { image: ({ value }) => , callToAction: ({ value }) => , }, // 3. Marks (inline decorators and annotations) marks: { strong: ({ children }) => {children}, em: ({ children }) => {children}, link: ({ children, value }) => { const rel = !value.href.startsWith("/") ? "noreferrer noopener" : undefined; return {children}; }, }, // 4. Lists list: { bullet: ({ children }) => , number: ({ children }) =>
    {children}
, }, }; ``` ## 3. Component Categories Portable Text has three types of custom components, each with different patterns: | Type | Examples | Pattern | |------|----------|---------| | **Block styles** | h1, h2, blockquote, normal | Text blocks with `children` prop | | **Custom types** | image, video, callToAction | Non-text blocks with `value` prop | | **Marks** | link, strong, productRef | Inline annotations wrapping text | ## 4. Creating Block Style Components Block styles are text blocks like headings and paragraphs. For simple styling, inline components work fine: ```typescript block: { h2: ({ children }) =>

{children}

, normal: ({ children }) =>

{children}

, } ``` ### With Visual Editing Support For live editing in the Presentation Tool, block style components may need **both** a client and server version: ```typescript // Heading2.tsx (Server - simple SSR for production) export function Heading2({ children }: { children: React.ReactNode }) { return

{children}

; } // Heading2Client.tsx (Client - for visual editing context) 'use client' export function Heading2Client({ children, value }: { children: React.ReactNode; value: any }) { // Can access block data via `value` for advanced patterns return

{children}

; } ``` Use `useIsPresentationTool` to conditionally render the client version: ```typescript import { useIsPresentationTool } from 'next-sanity/hooks' function Heading2Wrapper(props) { const isPresentationTool = useIsPresentationTool() if (isPresentationTool) { return } return } ``` ## 5. Creating Custom Type Components Custom types are non-text blocks like images, videos, or CTAs embedded in rich text. ### Schema Definition ```typescript // schemaTypes/blocks/pteImageBlock.ts import { defineType, defineField } from 'sanity' export const pteImageBlock = defineType({ name: 'pteImage', title: 'Image', type: 'object', fields: [ defineField({ name: 'image', type: 'image', options: { hotspot: true } }), defineField({ name: 'caption', type: 'string' }), defineField({ name: 'alt', type: 'string', validation: (r) => r.required() }), ], preview: { select: { title: 'caption', media: 'image' }, }, }) ``` ### Register in Body Schema ```typescript defineField({ name: 'body', type: 'array', of: [ { type: 'block' }, // Standard text { type: 'pteImage' }, // Custom image block { type: 'pteVideo' }, // Custom video block ], }) ``` ### Frontend Component ```typescript // PteImageComponent.tsx 'use client' type PteImageProps = { value: { _key: string image: any caption?: string alt: string } } export function PteImageComponent({ value }: PteImageProps) { if (!value.image) return null return (
{value.caption && (
{value.caption}
)}
) } // Register in components const components: PortableTextComponents = { types: { pteImage: PteImageComponent, }, } ``` ## 6. Creating Mark Components Marks are inline annotations that wrap text—links, highlights, or custom references. ### Schema Definition (Annotation) ```typescript // In your block configuration defineField({ name: 'body', type: 'array', of: [ { type: 'block', marks: { decorators: [ { title: 'Strong', value: 'strong' }, { title: 'Emphasis', value: 'em' }, { title: 'Highlight', value: 'highlight' }, ], annotations: [ { name: 'link', type: 'object', title: 'Link', fields: [ { name: 'href', type: 'url', title: 'URL' }, { name: 'openInNewTab', type: 'boolean', title: 'Open in new tab' }, ], }, { name: 'productRef', type: 'object', title: 'Product Reference', fields: [ { name: 'product', type: 'reference', to: [{ type: 'product' }] }, ], }, ], }, }, ], }) ``` ### Frontend Component ```typescript // LinkMark.tsx type LinkMarkProps = { children: React.ReactNode value: { href: string openInNewTab?: boolean } } export function LinkMark({ children, value }: LinkMarkProps) { const { href, openInNewTab } = value const target = openInNewTab ? '_blank' : undefined const rel = openInNewTab ? 'noopener noreferrer' : undefined return ( {children} ) } // Register in components const components: PortableTextComponents = { marks: { link: LinkMark, highlight: ({ children }) => {children}, }, } ``` ## 7. Presentation Queries for PTE Blocks For faster live editing of custom PTE blocks, use presentation queries that fetch only the specific block: ```typescript // queries.ts export const PTE_IMAGE_PRESENTATION_QUERY = defineQuery(` *[_id == $documentId][0]{ _id, _type, "pteImageBlock": body[_key == $blockKey && _type == "pteImage"][0]{ _key, image, caption, alt } } `) ``` Then in your component: ```typescript 'use client' import { usePresentationQuery } from 'next-sanity/hooks' export function PteImageComponent({ value, documentId }: { value: any; documentId?: string }) { const { data } = usePresentationQuery({ query: PTE_IMAGE_PRESENTATION_QUERY, params: { documentId, blockKey: value._key }, }) const blockData = data?.pteImageBlock || value // ... render with blockData } ``` **Note:** You'll need to pass `documentId` through to your PTE components. See `visual-editing.md` for context patterns. ## 8. GROQ Fragment for PTE When querying documents with Portable Text, expand custom blocks: ```groq *[_type == "article" && slug.current == $slug][0]{ ..., body[]{ ..., _type == "pteImage" => { ..., "imageUrl": image.asset->url }, _type == "pteVideo" => { ..., video->{ title, url } } } } ``` ## 9. Stega and Visual Editing When Visual Editing is enabled, text content contains invisible stega characters for click-to-edit functionality. **For text rendering:** Let stega characters pass through—they enable overlays: ```typescript // Good - stega preserved for click-to-edit

{children}

``` **For logic/comparisons:** Clean the values first: ```typescript import { stegaClean } from '@sanity/client/stega' // Clean before using in logic const cleanedStyle = stegaClean(block.style) if (cleanedStyle === 'h2') { ... } ``` ## 10. Type Safety When using TypeGen, the Portable Text value usually has a complex generated type. You can often use `any` or `PortableTextBlock[]` for the *prop*, but cast specific blocks if needed. ```typescript import { PortableTextBlock } from "next-sanity"; type Props = { value: PortableTextBlock[]; }; ``` ## 11. Best Practices - **Tailwind Typography:** For simple blogs, wrap `` in a `
` (from `@tailwindcss/typography`) instead of manually styling every block. - **Handling Nulls:** Always check if `value` exists and is an array before rendering. - **Keys:** The `PortableText` component handles React keys automatically using the `_key` from Sanity. Do not add keys manually. - **Separate from Page Builder:** PTE blocks live in `body[]` (rich text fields), not `pageBuilder[]` (page layout). Keep these patterns separate.