Add Portable Text Block
/add-portable-text-blockCreated: 19 Sept 2025, 11:46Updated: 19 Sept 2025, 16:08Variables
1
Goals
1
Path Groups
2
Nodes
13
Compact File Tree
Quick overview of planned files
Base Path
frontendโโ ๐ app
โโ ๐ components
โโ ๐ portable-text-components
โ โโ ๐ portable-blocks
โ โโ ๐ Block{{.PascalCaseBlockTitle}}.tsx
โ โโ ๐ index.ts
โโ ๐ PortableText.tsxBase Path
/studio/srcโโ ๐ schemaTypes
โโ ๐ objects
โโ ๐ portable-text-components
โ โโ ๐ portable-text-blocks
โ โโ ๐ Block{{.PascalCaseBlockTitle}}Preview.tsx
โโ ๐ blockContent.tsxVariables
Argument-driven inputs used by your generator
- P1BlockTitleBlockTitle
Human-friendly label shown in the Studio style dropdown.
Custom HeadingLead ParagraphIntroCallout
Ignored Patterns
Globs/paths skipped by the executor
No ignored patterns.
Goals
What this command is trying to accomplish
- Wire a custom Portable Text block style across Studio & Frontend
Adds a reusable block style to the Studio editor (with optional preview component) and renders it on the frontend by extending the PortableText block map.
File Tree
Detailed view with actions
Base Path
frontendDetailed view
- appFolder
- componentsFolder
- portable-text-componentsFolder
- portable-blocksFolder
- Block{{.PascalCaseBlockTitle}}.tsxFile
View Code
import * as React from "react" export default function Block{{.PascalCaseBlockTitle}}({ children }: { children: React.ReactNode }) { return ( <div className="py-6 px-2 bg-primary"> <h1 className="text-primary-foreground"> {children} </h1> </span> ) } - index.tsFile โข Action FileActions
- Import Block component into portable-blocks indexBehaviour: addMarkerAboveTargetOccurrence: firstTarget:
const portableBlocks = {Content
import Block{{.PascalCaseBlockTitle}} from "./Block{{.PascalCaseBlockTitle}}" - Register block style in portable-blocks mapBehaviour: addMarkerBelowTargetOccurrence: firstTarget:
const portableBlocks = {Content
{{.PortableBlockTitle}}: Block{{.PascalCaseBlockTitle}},
View Code
const portableBlocks = { } export default portableBlocks;
- PortableText.tsxFile โข Action FileActions
- Import portable-blocks into PortableTextBehaviour: addMarkerBelowTargetOccurrence: firstTarget:
import {PortableText, type PortableTextComponents, type PortableTextBlock} from 'next-sanity'Content
import portableBlocks from './portable-text-components/portable-blocks'
- Spread custom blocks into PortableText block mapBehaviour: addMarkerBelowTargetOccurrence: firstTarget:
block: {Content
...portableBlocks,
View Code
/** * This component uses Portable Text to render a post body. * * You can learn more about Portable Text on: * https://www.sanity.io/docs/block-content * https://github.com/portabletext/react-portabletext * https://portabletext.org/ * */ import {PortableText, type PortableTextComponents, type PortableTextBlock} from 'next-sanity' import portableTypes from './portable-text-components/portable-marks' import ResolvedLink from '@/app/components/ResolvedLink' export default function CustomPortableText({ className, value, }: { className?: string value: PortableTextBlock[] }) { const components: PortableTextComponents = { types: { ...portableTypes, }, block: { h1: ({children, value}) => ( // Add an anchor to the h1 <h1 className="group relative"> {children} <a href={`#${value?._key}`} className="absolute left-0 top-0 bottom-0 -ml-6 flex items-center opacity-0 group-hover:opacity-100 transition-opacity" > <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /> </svg> </a> </h1> ), h2: ({children, value}) => { // Add an anchor to the h2 return ( <h2 className="group relative"> {children} <a href={`#${value?._key}`} className="absolute left-0 top-0 bottom-0 -ml-6 flex items-center opacity-0 group-hover:opacity-100 transition-opacity" > <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /> </svg> </a> </h2> ) }, }, marks: { link: ({children, value: link}) => { return <ResolvedLink link={link}>{children}</ResolvedLink> }, }, } return ( <div className={['prose prose-a:text-brand', className].filter(Boolean).join(' ')}> <PortableText components={components} value={value} /> </div> ) }
Base Path
/studio/srcDetailed view
- schemaTypesFolder
- objectsFolder
- portable-text-componentsFolder
- portable-text-blocksFolder
- Block{{.PascalCaseBlockTitle}}Preview.tsxFile
View Code
import React from 'react' export default function Block{{.PascalCaseBlockTitle}}Preview(props: any) { return ( <span style={{ fontVariant: 'common-ligatures', display: 'inline' }}> {props.children} </span> ) }
- blockContent.tsxFile โข Action FileActions
- Import Studio block style preview componentBehaviour: addMarkerBelowTargetOccurrence: firstTarget:
import {defineArrayMember, defineType, defineField} from 'sanity'Content
import Block{{.PascalCaseBlockTitle}}Preview from './portable-text-components/portable-text-blocks/Block{{.PascalCaseBlockTitle}}Preview' - Create default styles with custom block styleBehaviour: replaceIfMissingOccurrence: firstTarget:
type: 'block',Content
styles: [ {title: 'Normal', value: 'normal'}, {title: 'H1', value: 'h1'}, {title: 'H2', value: 'h2'}, {title: 'H3', value: 'h3'}, {title: 'Quote', value: 'blockquote'}, { title: '{{.BlockTitle}}', value: '{{.PortableBlockStyle}}', component: Block{{.PascalCasePortableBlockStyle}}Preview, }, ], - Append custom block style to existing styles (if present)Behaviour: addMarkerBelowTargetOccurrence: firstTarget:
styles: [Content
{ title: '{{.BlockTitle}}', value: '{{.PortableBlockStyle}}', component: Block{{.PascalCaseBlockTitle}}Preview, },
View Code
import {defineArrayMember, defineType, defineField} from 'sanity' /** * This is the schema definition for the rich text fields used for * for this blog studio. When you import it in schemas.js it can be * reused in other parts of the studio with: * { * name: 'someName', * title: 'Some title', * type: 'blockContent' * } * * Learn more: https://www.sanity.io/docs/block-content */ export const blockContent = defineType({ title: 'Block Content', name: 'blockContent', type: 'array', of: [ defineArrayMember({ type: 'block', marks: { annotations: [ { name: 'link', type: 'object', title: 'Link', fields: [ defineField({ name: 'linkType', title: 'Link Type', type: 'string', initialValue: 'href', options: { list: [ {title: 'URL', value: 'href'}, {title: 'Page', value: 'page'}, {title: 'Post', value: 'post'}, ], layout: 'radio', }, }), defineField({ name: 'href', title: 'URL', type: 'url', hidden: ({parent}) => parent?.linkType !== 'href' && parent?.linkType != null, validation: (Rule) => Rule.custom((value, context: any) => { if (context.parent?.linkType === 'href' && !value) { return 'URL is required when Link Type is URL' } return true }), }), defineField({ name: 'page', title: 'Page', type: 'reference', to: [{type: 'page'}], hidden: ({parent}) => parent?.linkType !== 'page', validation: (Rule) => Rule.custom((value, context: any) => { if (context.parent?.linkType === 'page' && !value) { return 'Page reference is required when Link Type is Page' } return true }), }), defineField({ name: 'post', title: 'Post', type: 'reference', to: [{type: 'post'}], hidden: ({parent}) => parent?.linkType !== 'post', validation: (Rule) => Rule.custom((value, context: any) => { if (context.parent?.linkType === 'post' && !value) { return 'Post reference is required when Link Type is Post' } return true }), }), defineField({ name: 'openInNewTab', title: 'Open in new tab', type: 'boolean', initialValue: false, }), ], }, ], }, }), ], })
Raw JSON
Debug view of the fetched document
{
"_createdAt": "2025-09-19T11:46:38Z",
"_id": "56a03eb1-bfec-426e-8b2b-fbddb5b161ed",
"_rev": "3JcS3A5GjOK7pB9VbWIKwJ",
"_system": {
"base": {
"id": "56a03eb1-bfec-426e-8b2b-fbddb5b161ed",
"rev": "K4tdAJtZqIlru9YbYKtw58"
}
},
"_type": "command-slug",
"_updatedAt": "2025-09-19T16:08:21Z",
"description": "A line-based block you can have in your portable text content. (e.g., custom heading, lead paragraph, intro, callout)",
"filePaths": [
{
"id": "path-1758111573363-2uw6i9l5w",
"nodes": [
{
"_key": "1758114029952-6skyomi42",
"_type": "treeNode",
"actionFile": false,
"actions": [],
"children": [
{
"_key": "1758114044158-g3pao7lkk",
"_type": "treeNode",
"actionFile": false,
"actions": [],
"children": [
{
"_key": "1758115726249-139rkyblr",
"_type": "treeNode",
"actionFile": false,
"actions": [],
"children": [
{
"_key": "1758115748903-gybtimxk6",
"_type": "treeNode",
"actionFile": false,
"actions": [],
"children": [
{
"_key": "1758116035646-s7soez0mj",
"_type": "treeNode",
"actionFile": false,
"actions": [
{
"_key": "3JcS3A5GjOK7pB9VbWIKl5",
"logic": {},
"mark": ""
}
],
"children": [],
"code": "import * as React from \"react\"\n\nexport default function Block{{.PascalCaseBlockTitle}}({ children }: { children: React.ReactNode }) {\n \n return (\n <div className=\"py-6 px-2 bg-primary\">\n <h1 className=\"text-primary-foreground\">\n {children}\n </h1>\n </span>\n )\n}\n",
"id": "file-1758116035646",
"name": "Block{{.PascalCaseBlockTitle}}.tsx",
"nodeType": "file"
},
{
"_key": "1758115756801-jxu1o7xxr",
"_type": "treeNode",
"actionFile": true,
"actions": [
{
"_key": "3JcS3A5GjOK7pB9VbWIKm1",
"logic": {
"behaviour": "addMarkerAboveTarget",
"content": "import Block{{.PascalCaseBlockTitle}} from \"./Block{{.PascalCaseBlockTitle}}\"",
"occurrence": "first",
"requireAbsent": "Block{{.PascalCasePortableBlockTitle}}",
"target": "const portableBlocks = {"
},
"mark": "",
"title": "Import Block component into portable-blocks index"
},
{
"_key": "3JcS3A5GjOK7pB9VbWIKmx",
"logic": {
"behaviour": "addMarkerBelowTarget",
"content": " {{.PortableBlockTitle}}: Block{{.PascalCaseBlockTitle}},",
"occurrence": "first",
"requireAbsent": "{{.PortableBlockTitle}}:",
"target": "const portableBlocks = {"
},
"mark": "",
"title": "Register block style in portable-blocks map"
}
],
"children": [],
"code": "const portableBlocks = {\n}\n\nexport default portableBlocks;",
"id": "file-1758115756801",
"name": "index.ts",
"nodeType": "file"
}
],
"code": "",
"id": "folder-1758115748903",
"name": "portable-blocks",
"nodeType": "folder"
}
],
"code": "",
"id": "folder-1758115726249",
"name": "portable-text-components",
"nodeType": "folder"
},
{
"_key": "1758114053022-0lp5lu1ki",
"_type": "treeNode",
"actionFile": true,
"actions": [
{
"_key": "3JcS3A5GjOK7pB9VbWIKnt",
"logic": {
"behaviour": "addMarkerBelowTarget",
"content": "import portableBlocks from './portable-text-components/portable-blocks'",
"occurrence": "first",
"requireAbsent": "import portableBlocks from './portable-text-components/portable-blocks'",
"target": "import {PortableText, type PortableTextComponents, type PortableTextBlock} from 'next-sanity'"
},
"mark": "",
"title": "Import portable-blocks into PortableText"
},
{
"_key": "3JcS3A5GjOK7pB9VbWIKop",
"logic": {
"behaviour": "addMarkerBelowTarget",
"content": " ...portableBlocks,",
"occurrence": "first",
"requireAbsent": "...portableBlocks",
"target": "block: {"
},
"mark": "",
"title": "Spread custom blocks into PortableText block map"
}
],
"children": [],
"code": "/**\n * This component uses Portable Text to render a post body.\n *\n * You can learn more about Portable Text on:\n * https://www.sanity.io/docs/block-content\n * https://github.com/portabletext/react-portabletext\n * https://portabletext.org/\n *\n */\n\nimport {PortableText, type PortableTextComponents, type PortableTextBlock} from 'next-sanity'\nimport portableTypes from './portable-text-components/portable-marks'\n\nimport ResolvedLink from '@/app/components/ResolvedLink'\n\nexport default function CustomPortableText({\n className,\n value,\n}: {\n className?: string\n value: PortableTextBlock[]\n}) {\n const components: PortableTextComponents = {\n types: {\n ...portableTypes,\n },\n block: {\n h1: ({children, value}) => (\n // Add an anchor to the h1\n <h1 className=\"group relative\">\n {children}\n <a\n href={`#${value?._key}`}\n className=\"absolute left-0 top-0 bottom-0 -ml-6 flex items-center opacity-0 group-hover:opacity-100 transition-opacity\"\n >\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n className=\"h-4 w-4\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n stroke=\"currentColor\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1\"\n />\n </svg>\n </a>\n </h1>\n ),\n h2: ({children, value}) => {\n // Add an anchor to the h2\n return (\n <h2 className=\"group relative\">\n {children}\n <a\n href={`#${value?._key}`}\n className=\"absolute left-0 top-0 bottom-0 -ml-6 flex items-center opacity-0 group-hover:opacity-100 transition-opacity\"\n >\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n className=\"h-4 w-4\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n stroke=\"currentColor\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1\"\n />\n </svg>\n </a>\n </h2>\n )\n },\n },\n marks: {\n link: ({children, value: link}) => {\n return <ResolvedLink link={link}>{children}</ResolvedLink>\n },\n },\n }\n\n return (\n <div className={['prose prose-a:text-brand', className].filter(Boolean).join(' ')}>\n <PortableText components={components} value={value} />\n </div>\n )\n}\n",
"id": "file-1758114053022",
"name": "PortableText.tsx",
"nodeType": "file"
}
],
"code": "",
"id": "folder-1758114044158",
"name": "components",
"nodeType": "folder"
}
],
"code": "",
"id": "folder-1758114029952",
"name": "app",
"nodeType": "folder"
}
],
"path": "frontend"
},
{
"id": "path-1758109429918",
"nodes": [
{
"_key": "1758110151454-1v3b9778v",
"_type": "treeNode",
"actionFile": false,
"actions": [],
"children": [
{
"_key": "1758109431965-lkxvfd11k",
"_type": "treeNode",
"actionFile": false,
"actions": [],
"children": [
{
"_key": "1758109548620-mhlaenwvf",
"_type": "treeNode",
"actionFile": false,
"actions": [],
"children": [
{
"_key": "1758109732800-ovgdbke3h",
"_type": "treeNode",
"actionFile": false,
"actions": [],
"children": [
{
"_key": "1758110289262-qkvv7l69f",
"_type": "treeNode",
"actionFile": false,
"actions": [
{
"_key": "3JcS3A5GjOK7pB9VbWIKpl",
"logic": {},
"mark": ""
}
],
"children": [],
"code": "import React from 'react'\n\nexport default function Block{{.PascalCaseBlockTitle}}Preview(props: any) {\n return (\n <span style={{ fontVariant: 'common-ligatures', display: 'inline' }}>\n {props.children}\n </span>\n )\n}\n",
"id": "file-1758110289262",
"name": "Block{{.PascalCaseBlockTitle}}Preview.tsx",
"nodeType": "file"
}
],
"code": "",
"id": "folder-1758109732800",
"name": "portable-text-blocks",
"nodeType": "folder"
}
],
"code": "",
"id": "folder-1758109548620",
"name": "portable-text-components",
"nodeType": "folder"
},
{
"_key": "1758109462849-a1dhia33f",
"_type": "treeNode",
"actionFile": true,
"actions": [
{
"_key": "3JcS3A5GjOK7pB9VbWIKqh",
"logic": {
"behaviour": "addMarkerBelowTarget",
"content": "import Block{{.PascalCaseBlockTitle}}Preview from './portable-text-components/portable-text-blocks/Block{{.PascalCaseBlockTitle}}Preview'",
"occurrence": "first",
"requireAbsent": "Block{{.PascalCasePortableBlockStyle}}Preview",
"target": "import {defineArrayMember, defineType, defineField} from 'sanity'"
},
"mark": "",
"title": "Import Studio block style preview component"
},
{
"_key": "3JcS3A5GjOK7pB9VbWIKrd",
"logic": {
"behaviour": "replaceIfMissing",
"content": " styles: [\n {title: 'Normal', value: 'normal'},\n {title: 'H1', value: 'h1'},\n {title: 'H2', value: 'h2'},\n {title: 'H3', value: 'h3'},\n {title: 'Quote', value: 'blockquote'},\n {\n title: '{{.BlockTitle}}',\n value: '{{.PortableBlockStyle}}',\n component: Block{{.PascalCasePortableBlockStyle}}Preview,\n },\n ],",
"occurrence": "first",
"replacement": " type: 'block',\n styles: [\n {title: 'Normal', value: 'normal'},\n {title: 'H1', value: 'h1'},\n {title: 'H2', value: 'h2'},\n {title: 'H3', value: 'h3'},\n {title: 'Quote', value: 'blockquote'},\n {\n title: '{{.BlockTitle}}',\n value: '{{.PortableBlockTitle}}',\n component: Block{{.PascalCaseBlockTitle}}Preview,\n },\n ],",
"requireAbsent": "styles: [",
"target": "type: 'block',",
"targetStart": "type: 'block',"
},
"mark": "",
"title": "Create default styles with custom block style"
},
{
"_key": "3JcS3A5GjOK7pB9VbWIKsZ",
"logic": {
"behaviour": "addMarkerBelowTarget",
"content": " {\n title: '{{.BlockTitle}}',\n value: '{{.PortableBlockStyle}}',\n component: Block{{.PascalCaseBlockTitle}}Preview,\n },",
"occurrence": "first",
"requireAbsent": "value: '{{.PortableBlockStyle}}'",
"target": "styles: ["
},
"mark": "",
"title": "Append custom block style to existing styles (if present)"
}
],
"children": [],
"code": "import {defineArrayMember, defineType, defineField} from 'sanity'\n\n\n/**\n * This is the schema definition for the rich text fields used for\n * for this blog studio. When you import it in schemas.js it can be\n * reused in other parts of the studio with:\n * {\n * name: 'someName',\n * title: 'Some title',\n * type: 'blockContent'\n * }\n *\n * Learn more: https://www.sanity.io/docs/block-content\n */\nexport const blockContent = defineType({\n title: 'Block Content',\n name: 'blockContent',\n type: 'array',\n of: [\n defineArrayMember({\n type: 'block',\n marks: {\n annotations: [\n {\n name: 'link',\n type: 'object',\n title: 'Link',\n fields: [\n defineField({\n name: 'linkType',\n title: 'Link Type',\n type: 'string',\n initialValue: 'href',\n options: {\n list: [\n {title: 'URL', value: 'href'},\n {title: 'Page', value: 'page'},\n {title: 'Post', value: 'post'},\n ],\n layout: 'radio',\n },\n }),\n defineField({\n name: 'href',\n title: 'URL',\n type: 'url',\n hidden: ({parent}) => parent?.linkType !== 'href' && parent?.linkType != null,\n validation: (Rule) =>\n Rule.custom((value, context: any) => {\n if (context.parent?.linkType === 'href' && !value) {\n return 'URL is required when Link Type is URL'\n }\n return true\n }),\n }),\n defineField({\n name: 'page',\n title: 'Page',\n type: 'reference',\n to: [{type: 'page'}],\n hidden: ({parent}) => parent?.linkType !== 'page',\n validation: (Rule) =>\n Rule.custom((value, context: any) => {\n if (context.parent?.linkType === 'page' && !value) {\n return 'Page reference is required when Link Type is Page'\n }\n return true\n }),\n }),\n defineField({\n name: 'post',\n title: 'Post',\n type: 'reference',\n to: [{type: 'post'}],\n hidden: ({parent}) => parent?.linkType !== 'post',\n validation: (Rule) =>\n Rule.custom((value, context: any) => {\n if (context.parent?.linkType === 'post' && !value) {\n return 'Post reference is required when Link Type is Post'\n }\n return true\n }),\n }),\n defineField({\n name: 'openInNewTab',\n title: 'Open in new tab',\n type: 'boolean',\n initialValue: false,\n }),\n ],\n },\n ],\n },\n }),\n ],\n})",
"id": "file-1758109462849",
"name": "blockContent.tsx",
"nodeType": "file"
}
],
"code": "",
"id": "folder-1758109431965",
"name": "objects",
"nodeType": "folder"
}
],
"code": "",
"id": "folder-1758110151454",
"name": "schemaTypes",
"nodeType": "folder"
}
],
"path": "/studio/src"
}
],
"goals": [
{
"_key": "b2f311f75c6e56651a0beea3a678ea38",
"description": "Adds a reusable block style to the Studio editor (with optional preview component) and renders it on the frontend by extending the PortableText block map.",
"fileHints": [],
"howToTips": [],
"title": "Wire a custom Portable Text block style across Studio & Frontend"
}
],
"ignoredPatterns": [],
"slug": {
"_type": "slug",
"current": "add-portable-text-block"
},
"slugCurrent": "add-portable-text-block",
"title": "Add Portable Text Block",
"variables": [
{
"_type": "variableDefinition",
"description": "Human-friendly label shown in the Studio style dropdown.\n",
"examples": [
"Custom Heading",
"Lead Paragraph",
"Intro",
"Callout"
],
"name": "BlockTitle",
"priority": 1,
"title": "BlockTitle"
}
]
}