Skip to content

Releases: portabletext/react-portabletext

v6.2.0

Choose a tag to compare

@ecoscript ecoscript released this 06 May 11:09
d487bb9

Minor Changes

  • #309 575c9b4 Thanks @stipsan! - TypeGen-aware Portable Text components

    <PortableText> now infers the shape of every component handler from the value prop. When you pass a value typed by Sanity TypeGen, components.types, components.marks, components.block, components.list, and components.listItem all receive precise value props for the exact content the query returned.

    Three new utility types ship with this feature:

    • InferComponents<T> - same inference as the inline components prop, for hoisting components out of JSX.
    • InferStrictComponents<T> - strict variant that requires a handler for every inferred custom type, mark, block style, and list style, and rejects handlers that aren't in the schema (and therefore not visible to TypeGen).
    • InferValue<T> - derives a Portable Text array value type from any TypeGen query result type, useful for re-usable wrapper components.

    Schema

    Every example below assumes the same sanity.config.ts:

    // sanity.config.ts
    import {
      defineArrayMember,
      defineConfig,
      defineField,
      defineType,
    } from "sanity";
    
    export default defineConfig({
      name: "default",
      projectId: "abc123",
      dataset: "production",
      schema: {
        types: [
          defineType({
            name: "post",
            type: "document",
            fields: [
              defineField({ name: "title", type: "string" }),
              defineField({
                name: "content",
                type: "array",
                of: [
                  defineArrayMember({ type: "block" }),
                  defineArrayMember({
                    type: "image",
                    options: { hotspot: true },
                    fields: [defineField({ name: "alt", type: "string" })],
                  }),
                ],
              }),
            ],
          }),
        ],
      },
    });

    Before: hand-typing handlers

    Previously, every handler had to be typed by hand to mirror the generated query shape:

    // app/[slug]/page.tsx
    import { createClient } from "@sanity/client";
    import { createImageUrlBuilder } from "@sanity/image-url";
    import { PortableText } from "@portabletext/react";
    import { defineQuery } from "groq";
    
    const client = createClient({
      projectId: "abc123",
      dataset: "production",
      useCdn: true,
      apiVersion: "2026-05-04",
    });
    const builder = createImageUrlBuilder(client);
    
    export default async function Page({ slug }: { slug: string }) {
      const query = defineQuery(
        `*[_type == "post" && slug.current == $slug][0]{title,content}`
      );
      const data = await client.fetch(query, { slug });
    
      if (!data) return notFound();
    
      return (
        <article>
          <h1>{data.title}</h1>
          <PortableText
            components={{
              types: {
                image: ({
                  value,
                }: {
                  value: {
                    asset?: {
                      _ref: string;
                      _type: "reference";
                      _weak?: boolean;
                    };
                    hotspot?: {
                      _type: "sanity.imageHotspot";
                      x?: number;
                      y?: number;
                      height?: number;
                      width?: number;
                    };
                    crop?: {
                      _type: "sanity.imageCrop";
                      top?: number;
                      bottom?: number;
                      left?: number;
                      right?: number;
                    };
                    alt?: string;
                    _type: "image";
                    _key: string;
                  };
                }) => (
                  <img src={builder.image(value).url()} alt={value.alt || ""} />
                ),
              },
            }}
            value={data.content}
          />
        </article>
      );
    }

    After: automatic inference

    Now the same handler is fully typed straight from data.content:

    // app/[slug]/page.tsx
    import { createClient } from "@sanity/client";
    import { createImageUrlBuilder } from "@sanity/image-url";
    import { PortableText } from "@portabletext/react";
    import { defineQuery } from "groq";
    
    const client = createClient({
      projectId: "abc123",
      dataset: "production",
      useCdn: true,
      apiVersion: "2026-05-04",
    });
    const builder = createImageUrlBuilder(client);
    
    export default async function Page({ slug }: { slug: string }) {
      const query = defineQuery(
        `*[_type == "post" && slug.current == $slug][0]{title,content}`
      );
      const data = await client.fetch(query, { slug });
    
      if (!data) return notFound();
    
      return (
        <article>
          <h1>{data.title}</h1>
          <PortableText
            components={{
              types: {
                // value is fully typed from the query result, no annotation needed
                image: ({ value }) => (
                  <img src={builder.image(value).url()} alt={value.alt || ""} />
                ),
              },
            }}
            value={data.content}
          />
        </article>
      );
    }

    InferComponents: hoisting components without losing inference

    Move the components map out of JSX and keep the same inferred handler types:

    // app/[slug]/page.tsx
    import { createClient } from "@sanity/client";
    import { createImageUrlBuilder } from "@sanity/image-url";
    import { PortableText, type InferComponents } from "@portabletext/react";
    import { defineQuery } from "groq";
    
    const client = createClient({
      projectId: "abc123",
      dataset: "production",
      useCdn: true,
      apiVersion: "2026-05-04",
    });
    const builder = createImageUrlBuilder(client);
    
    export default async function Page({ slug }: { slug: string }) {
      const query = defineQuery(
        `*[_type == "post" && slug.current == $slug][0]{title,content}`
      );
      const data = await client.fetch(query, { slug });
    
      if (!data) return notFound();
    
      const components = {
        types: {
          image: ({ value }) => (
            <img src={builder.image(value).url()} alt={value.alt || ""} />
          ),
        },
      } satisfies InferComponents<typeof data.content>;
    
      return (
        <article>
          <h1>{data.title}</h1>
          <PortableText components={components} value={data.content} />
        </article>
      );
    }

    InferStrictComponents + InferValue: a strict, re-usable wrapper

    InferValue<SanityQueries[keyof SanityQueries]> collects every Portable Text item shape from every registered TypeGen query into an array value type, and InferStrictComponents requires a handler for each of them. Together they're perfect for a single CustomPortableText you reuse across the app:

    // app/[slug]/page.tsx
    import { createClient, type SanityQueries } from "@sanity/client";
    import { createImageUrlBuilder } from "@sanity/image-url";
    import {
      PortableText,
      type InferStrictComponents,
      type InferValue,
    } from "@portabletext/react";
    import { defineQuery } from "groq";
    
    const client = createClient({
      projectId: "abc123",
      dataset: "production",
      useCdn: true,
      apiVersion: "2026-05-04",
    });
    const builder = createImageUrlBuilder(client);
    
    // Array value type for every Portable Text item shape across all registered queries.
    type PortableTextValue = InferValue<SanityQueries[keyof SanityQueries]>;
    
    function CustomPortableText({ value }: { value: PortableTextValue }) {
      const components = {
        types: {
          image: ({ value }) => (
            <img src={builder.image(value).url()} alt={value.alt || ""} />
          ),
        },
      } satisfies InferStrictComponents<PortableTextValue>;
      //   ^ TypeScript errors when the schema gains a custom type, mark, or list
      //     style without a matching handler defined here
    
      return <PortableText components={components} value={value} />;
    }
    
    export default async function Page({ slug }: { slug: string }) {
      const query = defineQuery(
        `*[_type == "post" && slug.current == $slug][0]{title,content}`
      );
      const data = await client.fetch(query, { slug });
    
      if (!data) return notFound();
    
      return (
        <article>
          <h1>{data.title}</h1>
          {Array.isArray(data.content) && (
            <CustomPortableText value={data.content} />
          )}
        </article>
      );
    }

v6.1.0

Choose a tag to compare

@ecoscript ecoscript released this 05 May 10:18
397c3d7

Minor Changes

v6.0.3

Choose a tag to compare

@ecoscript ecoscript released this 04 Mar 12:44
2eed34e

Patch Changes

v6.0.2

Choose a tag to compare

@ecoscript ecoscript released this 31 Dec 12:24
d804c7f

Patch Changes

v6.0.1

Choose a tag to compare

@ecoscript ecoscript released this 31 Dec 11:10
d49e2a8

Patch Changes

v6.0.0

Choose a tag to compare

@ecoscript ecoscript released this 09 Dec 15:34
693db98

Major Changes

Patch Changes

v5.0.0

Choose a tag to compare

@ecoscript ecoscript released this 04 Nov 23:33
5724ee3

Major Changes

v4.0.3

Choose a tag to compare

@ecoscript ecoscript released this 02 Sep 10:09
5a5b394

Patch Changes

v4.0.2

Choose a tag to compare

@ecoscript ecoscript released this 28 Aug 09:12
baa760b

Patch Changes

v4.0.1

Choose a tag to compare

@ecoscript ecoscript released this 21 Aug 19:59
4a081b3

Patch Changes