Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 100 additions & 128 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
`<PortableText>` now infers the shape of every component handler from the `value` prop. When you pass a value typed by [Sanity TypeGen](https://www.sanity.io/docs/apis-and-sdks/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.
Expand All @@ -20,41 +19,36 @@

```ts
// sanity.config.ts
import {
defineArrayMember,
defineConfig,
defineField,
defineType,
} from "sanity";
import {defineArrayMember, defineConfig, defineField, defineType} from 'sanity'

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

#### Before: hand-typing handlers
Expand All @@ -63,26 +57,24 @@

```tsx
// app/[slug]/page.tsx
import { createClient } from "@sanity/client";
import { createImageUrlBuilder } from "@sanity/image-url";
import { PortableText } from "@portabletext/react";
import { defineQuery } from "groq";
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",
projectId: 'abc123',
dataset: 'production',
useCdn: true,
apiVersion: "2026-05-04",
});
const builder = createImageUrlBuilder(client);
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 });
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();
if (!data) return notFound()

return (
<article>
Expand All @@ -95,37 +87,35 @@
}: {
value: {
asset?: {
_ref: string;
_type: "reference";
_weak?: boolean;
};
_ref: string
_type: 'reference'
_weak?: boolean
}
hotspot?: {
_type: "sanity.imageHotspot";
x?: number;
y?: number;
height?: number;
width?: number;
};
_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 || ""} />
),
_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>
);
)
}
```

Expand All @@ -135,26 +125,24 @@

```tsx
// app/[slug]/page.tsx
import { createClient } from "@sanity/client";
import { createImageUrlBuilder } from "@sanity/image-url";
import { PortableText } from "@portabletext/react";
import { defineQuery } from "groq";
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",
projectId: 'abc123',
dataset: 'production',
useCdn: true,
apiVersion: "2026-05-04",
});
const builder = createImageUrlBuilder(client);
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 });
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();
if (!data) return notFound()

return (
<article>
Expand All @@ -163,15 +151,13 @@
components={{
types: {
// value is fully typed from the query result, no annotation needed
image: ({ value }) => (
<img src={builder.image(value).url()} alt={value.alt || ""} />
),
image: ({value}) => <img src={builder.image(value).url()} alt={value.alt || ''} />,
},
}}
value={data.content}
/>
</article>
);
)
}
```

Expand All @@ -181,41 +167,37 @@

```tsx
// 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";
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",
projectId: 'abc123',
dataset: 'production',
useCdn: true,
apiVersion: "2026-05-04",
});
const builder = createImageUrlBuilder(client);
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 });
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();
if (!data) return notFound()

const components = {
types: {
image: ({ value }) => (
<img src={builder.image(value).url()} alt={value.alt || ""} />
),
image: ({value}) => <img src={builder.image(value).url()} alt={value.alt || ''} />,
},
} satisfies InferComponents<typeof data.content>;
} satisfies InferComponents<typeof data.content>

return (
<article>
<h1>{data.title}</h1>
<PortableText components={components} value={data.content} />
</article>
);
)
}
```

Expand All @@ -225,56 +207,46 @@

```tsx
// 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";
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",
projectId: 'abc123',
dataset: 'production',
useCdn: true,
apiVersion: "2026-05-04",
});
const builder = createImageUrlBuilder(client);
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]>;
type PortableTextValue = InferValue<SanityQueries[keyof SanityQueries]>

function CustomPortableText({ value }: { value: PortableTextValue }) {
function CustomPortableText({value}: {value: PortableTextValue}) {
const components = {
types: {
image: ({ value }) => (
<img src={builder.image(value).url()} alt={value.alt || ""} />
),
image: ({value}) => <img src={builder.image(value).url()} alt={value.alt || ''} />,
},
} satisfies InferStrictComponents<PortableTextValue>;
} 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} />;
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 });
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();
if (!data) return notFound()

return (
<article>
<h1>{data.title}</h1>
{Array.isArray(data.content) && (
<CustomPortableText value={data.content} />
)}
{Array.isArray(data.content) && <CustomPortableText value={data.content} />}
</article>
);
)
}
```

Expand Down
Loading