Add Page Type with Pagebuilder
/add-page-type-with-pagebuilderCreated: 15 Sept 2025, 12:11Updated: 16 Sept 2025, 12:13frontendββ π app
β ββ π {{.KebabCasePageTypePlural}}
β β ββ π (index)
β β β ββ π page.tsx
β β ββ π [slug]
β β ββ π page.tsx
β ββ π components
β ββ π {{.PascalCasePageTypePlural}}.tsx
β ββ π Header.tsx
ββ π sanity
ββ π lib
ββ π pagetype-queries
β ββ π {{.KebabCasePageTypeSingular}}.queries.ts
ββ π queries.ts
ββ π utils.tsstudio/src/schemaTypesββ π documents
β ββ π {{.KebabCasePageTypeSingular}}.ts
ββ π objects
β ββ π blockContent.tsx
β ββ π link.ts
β ββ π pageBuilder.tsx
ββ π index.ts- P1PageTypeSingularName your page type
This is the name of the "_type" we use in Sanity. This dictates a lot of the naming conventions elsewhere.
authoreventproductservice - P2PageTypePluralPluralize your page type
This helps cases where we need to pluralize. No worries if it's the same as the singular.
authorseventsproductsservices
- Create Files and Folders for the Page Type routing
Creates folder for the route, with dedicated page.tsx for both (index) and [slug].
- Create List Component which output posts from new PageType
- Create dedicated query file for the new PageType
- Register PageBuilder object in Studio schema indexstudio/src/schemaTypes/index.ts
Makes the Page Builder UI available in Studio so the new PageType can actually use its blocks.
How-To Tips
- Add the import near other object imports (use an addMarkerAboveTarget-style insertion; guard with requireAbsent).
- Include 'pageBuilder' in the schemaTypes array close to other objects (addMarkerAboveTarget; guard with requireAbsent).
- Register PageType in Studio schema indexstudio/src/schemaTypes/index.ts
Allows Studio to recognize and edit the new document type, which is foundational for all other steps.
How-To Tips
- Place the import alongside other document imports (addMarkerBelowTarget fits well).
- Append the type in the documents section of schemaTypes (addMarkerAboveTarget).
- Export shared GROQ fragments (postFields, linkFields, linkReference)frontend/sanity/lib/queries.ts
Turns common fragments into exports so queries across files reuse them cleanly without re-definitions.
How-To Tips
- Find the primary 'const' definitions and convert to 'export const' (replaceIfMissing).
- Change only the first canonical definition and guard with requireAbsent to avoid double-exports.
- Export pageBuilderFields and reuse in getPageQueryfrontend/sanity/lib/queries.ts
Centralizes Page Builder selections into a shared fragment and references it in queries to prevent drift.
How-To Tips
- Introduce an exported fragment near other exports and rebind the query to use it (replaceBetween).
- Guard with requireAbsent so you donβt redefine an existing export.
- Add PageType to linkReference mapping (rich-text links)frontend/sanity/lib/queries.ts
Lets rich-text links resolve this new type by exposing its slug in the mapping used across queries.
How-To Tips
- Locate the object that maps linkable types to slugs.
- Insert a new key for the PageType using an addMarkerBelowTarget-style placement near the start of the mapping.
- Add PageType route case to linkResolverfrontend/sanity/lib/utils.ts
Teaches the client resolver how to build canonical URLs for the new type so internal links work end-to-end.
How-To Tips
- Find the switch/branch that handles link types.
- Add a case for the PageType before the default (addMarkerAboveTarget).
- Include PageType in sitemapData queryfrontend/sanity/lib/queries.ts
Ensures the new type is included in the sitemap so search engines can discover it.
How-To Tips
- Identify the allowed _type list in the sitemap filter.
- Insert the new _type right before the slug guard (insertBeforeInline; fallbackOnly to keep it idempotent).
- Add archive link to Header nav (idempotent)frontend/app/components/Header.tsx
Surfaces the new type in the main navigation so users can find its listing page.
How-To Tips
- Locate the first nav list item and place the archive link alongside similar internal links.
- Use addMarkerBelowTarget anchored to the earliest stable <li>.
- Keep it idempotent (occurrence 'first', fallbackOnly) and skip if an equivalent link exists.
- Expose PageType as a link type in blockContentstudio/src/schemaTypes/objects/blockContent.tsx
Lets editors choose the new type directly in rich-text link options for portable text blocks.
How-To Tips
- Find the editor-facing list of link type options.
- Add an option entry using addMarkerAboveTarget near the end of the list.
- Add conditional PageType reference field in blockContentstudio/src/schemaTypes/objects/blockContent.tsx
Shows a reference picker only when the new link type is selected, keeping forms tidy and valid.
How-To Tips
- Locate the group of link-related reference fields.
- Insert a conditional reference field using addMarkerAboveTarget at the final field cluster.
- Expose PageType as a link type in link objectstudio/src/schemaTypes/objects/link.ts
Adds the new type to the generic link object so any schema reusing it can target this type as well.
How-To Tips
- Find the radio/option list for link types.
- Add a new entry with addMarkerAboveTarget following the existing structure and ordering.
- Add conditional PageType reference field in link objectstudio/src/schemaTypes/objects/link.ts
Adds a reference field tied to the new option and required only when that option is chosen.
How-To Tips
- Identify where other type-specific reference fields are declared.
- Place the new conditional field alongside them (addMarkerAboveTarget).
frontend- appFolder
- {{.KebabCasePageTypePlural}}Folder
- (index)Folder
- page.tsxFile
View Code
import Link from "next/link"; import type { Metadata } from "next"; import { client } from "@/sanity/lib/client"; import { all{{.PascalCasePageTypePlural}}Query } from "@/sanity/lib/pagetype-queries/{{.KebabCasePageTypeSingular}}.queries"; import { All{{.PascalCasePageTypePlural}} } from "@/app/components/{{.PascalCasePageTypePlural}}"; export const metadata: Metadata = { title: "{{.PascalCasePageTypePlural}}", description: "All {{.LowerCasePageTypePlural}}" }; export default async function {{.PascalCasePageTypeSingular}}IndexPage() { const items = await client.fetch(all{{.PascalCasePageTypePlural}}Query); if (!items?.length) { return ( <main className="container mx-auto p-6"> <h1 className="text-2xl font-semibold">{{.PascalCasePageTypePlural}}</h1> <p className="opacity-70 mt-2">No {{.LowerCasePageTypePlural}} yet.</p> </main> ); } return ( <main className="container mx-auto p-6"> <All{{.PascalCasePageTypePlural}} /> </main> ); }
- [slug]Folder
- page.tsxFile
View Code
import type { Metadata, ResolvingMetadata } from 'next' import { sanityFetch } from '@/sanity/lib/live' import { {{.LowerCasePageTypeSingular}}Slugs, {{.LowerCasePageTypeSingular}}BySlugQuery } from '@/sanity/lib/pagetype-queries/{{.KebabCasePageTypeSingular}}.queries' import PageBuilderPage from '@/app/components/PageBuilder' import { PageOnboarding } from '@/app/components/Onboarding' type Props = { params: Promise<{ slug: string }> } /** * Generate the static params for {{.LowerCasePageTypeSingular}}. */ export async function generateStaticParams() { const { data } = await sanityFetch({ query: {{.LowerCasePageTypeSingular}}Slugs, perspective: 'published', stega: false, }) return data } /** * Generate metadata for the {{.LowerCasePageTypeSingular}} page. */ export async function generateMetadata(props: Props, _parent: ResolvingMetadata): Promise<Metadata> { const params = await props.params const { data: doc } = await sanityFetch({ query: {{.LowerCasePageTypeSingular}}BySlugQuery, params, stega: false, }) // Title/description fallbacks so this template works across different field sets const title = (doc?.name ?? doc?.title ?? '{{.PascalCasePageTypeSingular}}') as string | undefined const description = (doc?.heading ?? doc?.subheading ?? undefined) as string | undefined return { title, description, } satisfies Metadata } export default async function {{.PascalCasePageTypeSingular}}Page(props: Props) { const params = await props.params const [{ data: doc }] = await Promise.all([ sanityFetch({ query: {{.LowerCasePageTypeSingular}}BySlugQuery, params }), ]) if (!doc?._id) { return ( <div className="py-40"> <PageOnboarding /> </div> ) } return ( <div className="my-12 lg:my-24"> <div className=""> <div className="container"> <div className="pb-6 border-b border-gray-100"> <div className="max-w-3xl"> <h2 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl lg:text-7xl"> {doc?.heading ?? doc?.name ?? doc?.title} </h2> {(doc?.subheading ?? doc?.excerpt) && ( <p className="mt-4 text-base lg:text-lg leading-relaxed text-gray-600 uppercase font-light"> {doc?.subheading ?? doc?.excerpt} </p> )} </div> </div> </div> </div> {/* Keep your existing renderer */} <PageBuilderPage page={doc as any} /> </div> ) }
- componentsFolder
- {{.PascalCasePageTypePlural}}.tsxFile
View Code
import Link from 'next/link' import { sanityFetch } from '@/sanity/lib/live' import { all{{.PascalCasePageTypePlural}}Query } from '@/sanity/lib/pagetype-queries/{{.KebabCasePageTypeSingular}}.queries' import DateComponent from '@/app/components/Date' import OnBoarding from '@/app/components/Onboarding' import Avatar from '@/app/components/Avatar' import { createDataAttribute } from 'next-sanity' type {{.PascalCasePageTypeSingular}}ListItem = { _id: string title?: string name?: string slug: string excerpt?: string | null subheading?: string | null coverImage?: unknown date?: string author?: | { firstName?: string lastName?: string picture?: unknown } | null } const {{.PascalCasePageTypeSingular}}Card = ({ item }: { item: {{.PascalCasePageTypeSingular}}ListItem }) => { const { _id, slug, date, author } = item const title = item.title ?? item.name ?? 'Untitled' const excerpt = (item.excerpt ?? item.subheading) ?? null const attr = createDataAttribute({ id: _id, type: '{{.LowerCasePageTypeSingular}}', path: (item.title ? 'title' : 'name') as 'title' | 'name', }) return ( <article data-sanity={attr()} key={_id} className="border border-gray-200 rounded-sm p-6 bg-gray-50 flex flex-col justify-between transition-colors hover:bg-white relative" > <Link className="hover:text-brand underline transition-colors" href={`/{{.LowerCasePageTypePlural}}/${slug}`}> <span className="absolute inset-0 z-10" /> </Link> <div> <h3 className="text-2xl font-bold mb-4 leading-tight">{title}</h3> {excerpt && ( <p className="line-clamp-3 text-sm leading-6 text-gray-600 max-w-[70ch]">{excerpt}</p> )} </div> <div className="flex items-center justify-between mt-6 pt-4 border-t border-gray-100"> {author?.firstName && author?.lastName && ( <div className="flex items-center"> <Avatar person={author as any} small={true} /> </div> )} {date && ( <time className="text-gray-500 text-xs font-mono" dateTime={date}> <DateComponent dateString={date} /> </time> )} </div> </article> ) } const {{.PascalCasePageTypePlural}} = ({ children, heading, subHeading, }: { children: React.ReactNode heading?: string subHeading?: string }) => ( <div> {heading && ( <h2 className="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl lg:text-5xl"> {heading} </h2> )} {subHeading && <p className="mt-2 text-lg leading-8 text-gray-600">{subHeading}</p>} <div className="pt-6 space-y-6">{children}</div> </div> ) export const All{{.PascalCasePageTypePlural}} = async () => { const { data } = await sanityFetch({ query: all{{.PascalCasePageTypePlural}}Query }) if (!data || data.length === 0) { return <OnBoarding /> } const list = data as unknown as {{.PascalCasePageTypeSingular}}ListItem[] return ( <{{.PascalCasePageTypePlural}} heading="{{.PascalCasePageTypePlural}}" subHeading="{{.PascalCasePageTypePlural}} populated from your Sanity Studio." > {list.map((item) => ( <{{.PascalCasePageTypeSingular}}Card key={item._id} item={item} /> ))} </{{.PascalCasePageTypePlural}}> ) } - Header.tsxFile β’ Action FileActions
- PAGETYPE ARCHIVE LINKBehaviour: addMarkerBelowTargetOccurrence: firstTarget:
<li>Content
<Link href="/{{.LowerCasePageTypePlural}}" className="mr-6 hover:underline">{{.PascalCasePageTypePlural}}</Link>
View Code
import Link from 'next/link' import {settingsQuery} from '@/sanity/lib/queries' import {sanityFetch} from '@/sanity/lib/live' export default async function Header() { const {data: settings} = await sanityFetch({ query: settingsQuery, }) return ( <header className="fixed z-50 h-24 inset-0 bg-white/80 flex items-center backdrop-blur-lg"> <div className="container py-6 px-2 sm:px-6"> <div className="flex items-center justify-between gap-5"> <Link className="flex items-center gap-2" href="/"> <span className="text-lg sm:text-2xl pl-2 font-semibold"> {settings?.title || 'Sanity + Next.js'} </span> </Link> <nav> <ul role="list" className="flex items-center gap-4 md:gap-6 leading-5 text-xs sm:text-base tracking-tight font-mono" > <li> <Link href="/about" className="hover:underline"> About </Link> </li> <li className="sm:before:w-[1px] sm:before:bg-gray-200 before:block flex sm:gap-4 md:gap-6"> <Link className="rounded-full flex gap-4 items-center bg-black hover:bg-blue focus:bg-blue py-2 px-4 justify-center sm:py-3 sm:px-6 text-white transition-colors duration-200" href="https://github.com/sanity-io/sanity-template-nextjs-clean" target="_blank" rel="noopener noreferrer" > <span className="whitespace-nowrap">View on GitHub</span> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="hidden sm:block h-4 sm:h-6" > <path d="M12.001 2C6.47598 2 2.00098 6.475 2.00098 12C2.00098 16.425 4.86348 20.1625 8.83848 21.4875C9.33848 21.575 9.52598 21.275 9.52598 21.0125C9.52598 20.775 9.51348 19.9875 9.51348 19.15C7.00098 19.6125 6.35098 18.5375 6.15098 17.975C6.03848 17.6875 5.55098 16.8 5.12598 16.5625C4.77598 16.375 4.27598 15.9125 5.11348 15.9C5.90098 15.8875 6.46348 16.625 6.65098 16.925C7.55098 18.4375 8.98848 18.0125 9.56348 17.75C9.65098 17.1 9.91348 16.6625 10.201 16.4125C7.97598 16.1625 5.65098 15.3 5.65098 11.475C5.65098 10.3875 6.03848 9.4875 6.67598 8.7875C6.57598 8.5375 6.22598 7.5125 6.77598 6.1375C6.77598 6.1375 7.61348 5.875 9.52598 7.1625C10.326 6.9375 11.176 6.825 12.026 6.825C12.876 6.825 13.726 6.9375 14.526 7.1625C16.4385 5.8625 17.276 6.1375 17.276 6.1375C17.826 7.5125 17.476 8.5375 17.376 8.7875C18.0135 9.4875 18.401 10.375 18.401 11.475C18.401 15.3125 16.0635 16.1625 13.8385 16.4125C14.201 16.725 14.5135 17.325 14.5135 18.2625C14.5135 19.6 14.501 20.675 14.501 21.0125C14.501 21.275 14.6885 21.5875 15.1885 21.4875C19.259 20.1133 21.9999 16.2963 22.001 12C22.001 6.475 17.526 2 12.001 2Z"></path> </svg> </Link> </li> </ul> </nav> </div> </div> </header> ) }
- sanityFolder
- libFolder
- pagetype-queriesFolder
- {{.KebabCasePageTypeSingular}}.queries.tsFile
View Code
import { defineQuery } from "next-sanity"; import { linkFields, linkReference } from "../queries"; import { pageBuilderFields } from "../queries"; export const listFields = /* groq */ ` _id, "name": coalesce(name, "Untitled"), "slug": slug.current, heading, subheading `; // List (plural) export const all{{.PascalCasePageTypePlural}}Query = defineQuery(` *[_type == "{{.LowerCasePageTypeSingular}}" && defined(slug.current)] | order(_updatedAt desc) { ${listFields} } `); // By slug (singular) export const {{.LowerCasePageTypeSingular}}BySlugQuery = defineQuery(` *[_type == "{{.LowerCasePageTypeSingular}}" && slug.current == $slug][0]{ _id, _type, name, slug, heading, subheading, "pageBuilder": pageBuilder[]{ ${pageBuilderFields} }, }, } `); // Slugs only export const {{.LowerCasePageTypeSingular}}Slugs = defineQuery(` *[_type == "{{.LowerCasePageTypeSingular}}" && defined(slug.current)]{ "slug": slug.current } `);
- queries.tsFile β’ Action FileActions
- set linkFields to ExportBehaviour: replaceIfMissingOccurrence: firstTarget:
const linkFields = /* groq */ ` - Set postFields to ExportBehaviour: replaceIfMissingOccurrence: firstTarget:
const postFields = /* groq */ ` - Set linkReference to ExportBehaviour: replaceIfMissingOccurrence: firstTarget:
const linkReference = /* groq */ ` - connect up PageType as linkReferenceBehaviour: addMarkerBelowTargetOccurrence: firstTarget:
_type == "link" => {Content
"{{.LowerCasePageTypeSingular}}": {{.LowerCasePageTypeSingular}}->slug.current, - adding PageType to SitemapBehaviour: insertBeforeInlineOccurrence: firstTarget:
&& defined(slug.current)] | order(_type asc) {Content
|| _type == "{{.LowerCasePageTypeSingular}}" - Exportable PageBuilder FieldsBehaviour: replaceBetweenOccurrence: first
View Code
import {defineQuery} from 'next-sanity' export const settingsQuery = defineQuery(`*[_type == "settings"][0]`) export const postFields = /* groq */ ` _id, "status": select(_originalId in path("drafts.**") => "draft", "published"), "title": coalesce(title, "Untitled"), "slug": slug.current, excerpt, coverImage, "date": coalesce(date, _updatedAt), "author": author->{firstName, lastName, picture}, ` export const linkReference = /* groq */ ` _type == "link" => { "page": page->slug.current, "post": post->slug.current, } ` export const linkFields = /* groq */ ` link { ..., ${linkReference} } ` export const pageBuilderFields = /* groq */ ` ..., _type == "callToAction" => { ${linkFields}, }, _type == "infoSection" => { content[]{ ..., titleDefs[]{ ..., ${linkReference} } } } ` export const getPageQuery = defineQuery(` *[_type == 'page' && slug.current == $slug][0]{ _id, _type, name, slug, heading, subheading, "pageBuilder": pageBuilder[]{ ${pageBuilderFields} }, } `) export const sitemapData = defineQuery(` *[_type == "page" || _type == "post" && defined(slug.current)] | order(_type asc) { "slug": slug.current, _type, _updatedAt, } `) export const allPostsQuery = defineQuery(` *[_type == "post" && defined(slug.current)] | order(date desc, _updatedAt desc) { ${postFields} } `) export const morePostsQuery = defineQuery(` *[_type == "post" && _id != $skip && defined(slug.current)] | order(date desc, _updatedAt desc) [0...$limit] { ${postFields} } `) export const postQuery = defineQuery(` *[_type == "post" && slug.current == $slug] [0] { content[]{ ..., titleDefs[]{ ..., ${linkReference} } }, ${postFields} } `) export const postPagesSlugs = defineQuery(` *[_type == "post" && defined(slug.current)] {"slug": slug.current} `) export const pagesSlugs = defineQuery(` *[_type == "page" && defined(slug.current)] {"slug": slug.current} `) - utils.tsFile β’ Action FileActions
- Setting up routing for PageTypeBehaviour: addMarkerAboveTargetOccurrence: firstTarget:
default:Content
case '{{.LowerCasePageTypeSingular}}': { const slug = (link as any)?.['{{.LowerCasePageTypeSingular}}'] return typeof slug === 'string' ? `/{{.LowerCasePageTypePlural}}/${slug}` : null }
View Code
import createImageUrlBuilder from '@sanity/image-url' import {Link} from '@/sanity.types' import {dataset, projectId, studioUrl} from '@/sanity/lib/api' import {createDataAttribute, CreateDataAttributeProps} from 'next-sanity' import {getImageDimensions} from '@sanity/asset-utils' const imageBuilder = createImageUrlBuilder({ projectId: projectId || '', dataset: dataset || '', }) export const urlForImage = (source: any) => { // Ensure that source image contains a valid reference if (!source?.asset?._ref) { return undefined } const imageRef = source?.asset?._ref const crop = source.crop // get the image's og dimensions const {width, height} = getImageDimensions(imageRef) if (Boolean(crop)) { // compute the cropped image's area const croppedWidth = Math.floor(width * (1 - (crop.right + crop.left))) const croppedHeight = Math.floor(height * (1 - (crop.top + crop.bottom))) // compute the cropped image's position const left = Math.floor(width * crop.left) const top = Math.floor(height * crop.top) // gather into a url return imageBuilder?.image(source).rect(left, top, croppedWidth, croppedHeight).auto('format') } return imageBuilder?.image(source).auto('format') } export function resolveOpenGraphImage(image: any, width = 1200, height = 627) { if (!image) return const url = urlForImage(image)?.width(1200).height(627).fit('crop').url() if (!url) return return {url, alt: image?.alt as string, width, height} } // Depending on the type of link, we need to fetch the corresponding page, post, or URL. Otherwise return null. export function linkResolver(link: Link | undefined) { if (!link) return null // If linkType is not set but href is, lets set linkType to "href". This comes into play when pasting links into the portable text editor because a link type is not assumed. if (!link.linkType && link.href) { link.linkType = 'href' } switch (link.linkType) { case 'href': return link.href || null case 'page': if (link?.page && typeof link.page === 'string') { return `/${link.page}` } case 'post': if (link?.post && typeof link.post === 'string') { return `/posts/${link.post}` } default: return null } } type DataAttributeConfig = CreateDataAttributeProps & Required<Pick<CreateDataAttributeProps, 'id' | 'type' | 'path'>> export function dataAttr(config: DataAttributeConfig) { return createDataAttribute({ projectId, dataset, baseUrl: studioUrl, }).combine(config) }
studio/src/schemaTypes- documentsFolder
- {{.KebabCasePageTypeSingular}}.tsFile
View Code
import {defineField, defineType} from 'sanity' import { DocumentIcon } from '@sanity/icons' export const {{.LowerCasePageTypeSingular}} = defineType({ name: '{{.LowerCasePageTypeSingular}}', title: '{{.PascalCasePageTypeSingular}}', type: 'document', icon: DocumentIcon, fields: [ defineField({ name: 'name', title: 'Name', type: 'string', validation: (Rule) => Rule.required(), }), defineField({ name: 'slug', title: 'Slug', type: 'slug', validation: (Rule) => Rule.required(), options: { source: 'name', maxLength: 96, }, }), defineField({ name: 'heading', title: 'Heading', type: 'string', validation: (Rule) => Rule.required(), }), defineField({ name: 'subheading', title: 'Subheading', type: 'string', }), defineField({ name: 'pageBuilder', type: "pageBuilder" }), ], })
- objectsFolder
- blockContent.tsxFile β’ Action FileActions
- Adding PageType as a LinkType in BlockContentBehaviour: addMarkerAboveTargetOccurrence: firstTarget:
],Content
{title: '{{.PascalCasePageTypeSingular}}', value: '{{.LowerCasePageTypeSingular}}'}, - EXTRA INTERNAL LINK FIELDBehaviour: addMarkerAboveTargetOccurrence: lastTarget:
defineField({Content
defineField({ name: '{{.LowerCasePageTypeSingular}}', title: '{{.PascalCasePageTypeSingular}}', type: 'reference', to: [{type: '{{.LowerCasePageTypeSingular}}'}], hidden: ({parent}) => parent?.linkType !== '{{.LowerCasePageTypeSingular}}', validation: (Rule) => Rule.custom((value, context: any) => { if (context.parent?.linkType === '{{.LowerCasePageTypeSingular}}' && !value) { return '{{.PascalCasePageTypeSingular}} reference is required when Link Type is {{.PascalCasePageTypeSingular}}' } return true }), }),
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', titles: { 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, }), ], }, ], }, }), ], }) - link.tsFile β’ Action FileActions
- Adding PageType as a LinkType optionBehaviour: addMarkerAboveTargetOccurrence: firstTarget:
],Content
{title: '{{.PascalCasePageTypeSingular}}', value: '{{.LowerCasePageTypeSingular}}'}, - Adding PageType as a Link FieldBehaviour: addMarkerAboveTargetOccurrence: lastTarget:
defineField({Content
defineField({ name: '{{.LowerCasePageTypeSingular}}', title: '{{.PascalCasePageTypeSingular}}', type: 'reference', to: [{type: '{{.LowerCasePageTypeSingular}}'}], hidden: ({parent}) => parent?.linkType !== '{{.LowerCasePageTypeSingular}}', validation: (Rule) => Rule.custom((value, context: any) => { if (context.parent?.linkType === '{{.LowerCasePageTypeSingular}}' && !value) { return '{{.PascalCasePageTypeSingular}} reference is required when Link Type is {{.PascalCasePageTypeSingular}}' } return true }), }),
View Code
import {defineField, defineType} from 'sanity' import {LinkIcon} from '@sanity/icons' /** * Link schema object. This link object lets the user first select the type of link and then * then enter the URL, page reference, or post reference - depending on the type selected. * Learn more: https://www.sanity.io/docs/object-type */ export const link = defineType({ name: 'link', title: 'Link', type: 'object', icon: LinkIcon, fields: [ defineField({ name: 'linkType', title: 'Link Type', type: 'string', initialValue: 'url', options: { list: [ {title: 'URL', value: 'href'}, {title: 'Page', value: 'page'}, {title: 'Post', value: 'post'}, ], layout: 'radio', }, }), // URL defineField({ name: 'href', title: 'URL', type: 'url', hidden: ({parent}) => parent?.linkType !== 'href', validation: (Rule) => Rule.custom((value, context: any) => { if (context.parent?.linkType === 'href' && !value) { return 'URL is required when Link Type is URL' } return true }), }), // Page 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 }), }), // Post 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, }), ], }) - pageBuilder.tsxFile
View Code
import { defineType } from "sanity"; export const pageBuilder = defineType({ name: 'pageBuilder', title: 'Page builder', type: 'array', of: [{type: 'callToAction'}, {type: 'infoSection'}], options: { insertMenu: { // Configure the "Add Item" menu to display a thumbnail preview of the content type. https://www.sanity.io/docs/array-type#efb1fe03459d views: [ { name: 'grid', previewImageUrl: (schemaTypeName) => `/static/page-builder-thumbnails/${schemaTypeName}.webp`, }, ], }, } })
- index.tsFile β’ Action FileActions
- Importing PageType to SchemaTypos IndexBehaviour: addMarkerBelowTargetOccurrence: firstTarget:
import {post} from './documents/post'Content
import { {{.LowerCasePageTypeSingular}} } from './documents/{{.KebabCasePageTypeSingular}}' - Adding DocumentType to SchemaType arrayBehaviour: addMarkerAboveTargetOccurrence: lastTarget:
// ObjectsContent
{{.LowerCasePageTypeSingular}}, - Add Pagebuilder Object to SchemaTypesBehaviour: addMarkerAboveTargetOccurrence: lastTarget:
]Content
pageBuilder,
- Import PageBuilder Object to schemaTypes indexBehaviour: addMarkerAboveTargetOccurrence: firstTarget:
export const schemaTypes = [Content
import {pageBuilder} from './objects/pageBuilder'
View Code
import {person} from './documents/person' import {page} from './documents/page' import {post} from './documents/post' import {callToAction} from './objects/callToAction' import {infoSection} from './objects/infoSection' import {settings} from './singletons/settings' import {link} from './objects/link' import {blockContent} from './objects/blockContent' export const schemaTypes = [ // Singletons settings, // Documents page, post, person, // Objects blockContent, infoSection, callToAction, link, ]
{
"_createdAt": "2025-09-15T12:11:26Z",
"_id": "17963bea-94ef-4877-bec5-bdd2de85a31c",
"_rev": "fzxCIsOYL58G2O1cjyQMwa",
"_system": {
"base": {
"id": "17963bea-94ef-4877-bec5-bdd2de85a31c",
"rev": "G2Y6JXdwXQRnotb29gv4fr"
}
},
"_type": "command-slug",
"_updatedAt": "2025-09-16T12:13:24Z",
"description": "Instantly add a PageType with Page Builder across your Next.js frontend and Sanity Studio β generating a listing page (index / archive) and per-item detail pages (slug), wiring GROQ queries and client-side routing, and updating navigation and the sitemap for ready-to-publish coverage. ",
"filePaths": [
{
"id": "path-1757177493327-sb16mt1",
"nodes": [
{
"_key": "1757940166544-ae8vktjvy",
"_type": "treeNode",
"actionFile": false,
"actions": [],
"children": [
{
"_key": "20250903-front-index-folder",
"_type": "treeNode",
"actionFile": false,
"actions": [],
"children": [
{
"_key": "1757177532693-d16ts7xzg",
"_type": "treeNode",
"actionFile": false,
"actions": [],
"children": [
{
"_key": "20250903-front-index-file",
"_type": "treeNode",
"actionFile": false,
"actions": [
{
"_key": "kWkIvI7T6XFbABGiWe5cGp",
"logic": {},
"mark": ""
}
],
"children": [],
"code": "import Link from \"next/link\";\r\nimport type { Metadata } from \"next\";\r\nimport { client } from \"@/sanity/lib/client\";\r\nimport { all{{.PascalCasePageTypePlural}}Query } from \"@/sanity/lib/pagetype-queries/{{.KebabCasePageTypeSingular}}.queries\";\r\nimport { All{{.PascalCasePageTypePlural}} } from \"@/app/components/{{.PascalCasePageTypePlural}}\";\r\n\r\nexport const metadata: Metadata = {\r\n title: \"{{.PascalCasePageTypePlural}}\",\r\n description: \"All {{.LowerCasePageTypePlural}}\"\r\n};\r\n\r\nexport default async function {{.PascalCasePageTypeSingular}}IndexPage() {\r\n const items = await client.fetch(all{{.PascalCasePageTypePlural}}Query);\r\n\r\n if (!items?.length) {\r\n return (\r\n <main className=\"container mx-auto p-6\">\r\n <h1 className=\"text-2xl font-semibold\">{{.PascalCasePageTypePlural}}</h1>\r\n <p className=\"opacity-70 mt-2\">No {{.LowerCasePageTypePlural}} yet.</p>\r\n </main>\r\n );\r\n }\r\n\r\n return (\r\n <main className=\"container mx-auto p-6\">\r\n <All{{.PascalCasePageTypePlural}} />\r\n </main>\r\n );\r\n}\r\n",
"id": "file-frontend-index",
"name": "page.tsx",
"nodeType": "file"
}
],
"code": "",
"id": "folder-1757177532693",
"name": "(index)",
"nodeType": "folder"
},
{
"_key": "20250903-front-slug-param",
"_type": "treeNode",
"actionFile": false,
"actions": [],
"children": [
{
"_key": "20250903-front-slug-page",
"_type": "treeNode",
"actionFile": false,
"actions": [
{
"_key": "kWkIvI7T6XFbABGiWe5cLM",
"logic": {},
"mark": ""
}
],
"children": [],
"code": "import type { Metadata, ResolvingMetadata } from 'next'\r\nimport { sanityFetch } from '@/sanity/lib/live'\r\nimport { {{.LowerCasePageTypeSingular}}Slugs, {{.LowerCasePageTypeSingular}}BySlugQuery } from '@/sanity/lib/pagetype-queries/{{.KebabCasePageTypeSingular}}.queries'\r\nimport PageBuilderPage from '@/app/components/PageBuilder'\r\nimport { PageOnboarding } from '@/app/components/Onboarding'\r\n\r\ntype Props = {\r\n params: Promise<{ slug: string }>\r\n}\r\n\r\n/**\r\n * Generate the static params for {{.LowerCasePageTypeSingular}}.\r\n */\r\nexport async function generateStaticParams() {\r\n const { data } = await sanityFetch({\r\n query: {{.LowerCasePageTypeSingular}}Slugs,\r\n perspective: 'published',\r\n stega: false,\r\n })\r\n return data\r\n}\r\n\r\n/**\r\n * Generate metadata for the {{.LowerCasePageTypeSingular}} page.\r\n */\r\nexport async function generateMetadata(props: Props, _parent: ResolvingMetadata): Promise<Metadata> {\r\n const params = await props.params\r\n const { data: doc } = await sanityFetch({\r\n query: {{.LowerCasePageTypeSingular}}BySlugQuery,\r\n params,\r\n stega: false,\r\n })\r\n\r\n // Title/description fallbacks so this template works across different field sets\r\n const title = (doc?.name ?? doc?.title ?? '{{.PascalCasePageTypeSingular}}') as string | undefined\r\n const description = (doc?.heading ?? doc?.subheading ?? undefined) as string | undefined\r\n\r\n return {\r\n title,\r\n description,\r\n } satisfies Metadata\r\n}\r\n\r\nexport default async function {{.PascalCasePageTypeSingular}}Page(props: Props) {\r\n const params = await props.params\r\n\r\n const [{ data: doc }] = await Promise.all([\r\n sanityFetch({ query: {{.LowerCasePageTypeSingular}}BySlugQuery, params }),\r\n ])\r\n\r\n if (!doc?._id) {\r\n return (\r\n <div className=\"py-40\">\r\n <PageOnboarding />\r\n </div>\r\n )\r\n }\r\n\r\n return (\r\n <div className=\"my-12 lg:my-24\">\r\n <div className=\"\">\r\n <div className=\"container\">\r\n <div className=\"pb-6 border-b border-gray-100\">\r\n <div className=\"max-w-3xl\">\r\n <h2 className=\"text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl lg:text-7xl\">\r\n {doc?.heading ?? doc?.name ?? doc?.title}\r\n </h2>\r\n {(doc?.subheading ?? doc?.excerpt) && (\r\n <p className=\"mt-4 text-base lg:text-lg leading-relaxed text-gray-600 uppercase font-light\">\r\n {doc?.subheading ?? doc?.excerpt}\r\n </p>\r\n )}\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {/* Keep your existing renderer */}\r\n <PageBuilderPage page={doc as any} />\r\n </div>\r\n )\r\n}\r\n",
"id": "file-frontend-slug-page",
"name": "page.tsx",
"nodeType": "file"
}
],
"code": "",
"id": "folder-frontend-slug",
"name": "[slug]",
"nodeType": "folder"
}
],
"code": "",
"id": "folder-frontend-index",
"name": "{{.KebabCasePageTypePlural}}",
"nodeType": "folder"
},
{
"_key": "1757939590529-1aa1hehxl",
"_type": "treeNode",
"actionFile": false,
"actions": [],
"children": [
{
"_key": "1757176137674-q3yigc355",
"_type": "treeNode",
"actionFile": false,
"actions": [
{
"_key": "kWkIvI7T6XFbABGiWe5cPt",
"logic": {},
"mark": ""
}
],
"children": [],
"code": "import Link from 'next/link'\r\n\r\nimport { sanityFetch } from '@/sanity/lib/live'\r\nimport { all{{.PascalCasePageTypePlural}}Query } from '@/sanity/lib/pagetype-queries/{{.KebabCasePageTypeSingular}}.queries'\r\nimport DateComponent from '@/app/components/Date'\r\nimport OnBoarding from '@/app/components/Onboarding'\r\nimport Avatar from '@/app/components/Avatar'\r\nimport { createDataAttribute } from 'next-sanity'\r\n\r\ntype {{.PascalCasePageTypeSingular}}ListItem = {\r\n _id: string\r\n title?: string\r\n name?: string\r\n slug: string\r\n excerpt?: string | null\r\n subheading?: string | null\r\n coverImage?: unknown\r\n date?: string\r\n author?:\r\n | {\r\n firstName?: string\r\n lastName?: string\r\n picture?: unknown\r\n }\r\n | null\r\n}\r\n\r\nconst {{.PascalCasePageTypeSingular}}Card = ({ item }: { item: {{.PascalCasePageTypeSingular}}ListItem }) => {\r\n const { _id, slug, date, author } = item\r\n const title = item.title ?? item.name ?? 'Untitled'\r\n const excerpt = (item.excerpt ?? item.subheading) ?? null\r\n\r\n const attr = createDataAttribute({\r\n id: _id,\r\n type: '{{.LowerCasePageTypeSingular}}',\r\n path: (item.title ? 'title' : 'name') as 'title' | 'name',\r\n })\r\n\r\n return (\r\n <article\r\n data-sanity={attr()}\r\n key={_id}\r\n className=\"border border-gray-200 rounded-sm p-6 bg-gray-50 flex flex-col justify-between transition-colors hover:bg-white relative\"\r\n >\r\n <Link className=\"hover:text-brand underline transition-colors\" href={`/{{.LowerCasePageTypePlural}}/${slug}`}>\r\n <span className=\"absolute inset-0 z-10\" />\r\n </Link>\r\n\r\n <div>\r\n <h3 className=\"text-2xl font-bold mb-4 leading-tight\">{title}</h3>\r\n\r\n {excerpt && (\r\n <p className=\"line-clamp-3 text-sm leading-6 text-gray-600 max-w-[70ch]\">{excerpt}</p>\r\n )}\r\n </div>\r\n\r\n <div className=\"flex items-center justify-between mt-6 pt-4 border-t border-gray-100\">\r\n {author?.firstName && author?.lastName && (\r\n <div className=\"flex items-center\">\r\n <Avatar person={author as any} small={true} />\r\n </div>\r\n )}\r\n {date && (\r\n <time className=\"text-gray-500 text-xs font-mono\" dateTime={date}>\r\n <DateComponent dateString={date} />\r\n </time>\r\n )}\r\n </div>\r\n </article>\r\n )\r\n}\r\n\r\nconst {{.PascalCasePageTypePlural}} = ({\r\n children,\r\n heading,\r\n subHeading,\r\n}: {\r\n children: React.ReactNode\r\n heading?: string\r\n subHeading?: string\r\n}) => (\r\n <div>\r\n {heading && (\r\n <h2 className=\"text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl lg:text-5xl\">\r\n {heading}\r\n </h2>\r\n )}\r\n {subHeading && <p className=\"mt-2 text-lg leading-8 text-gray-600\">{subHeading}</p>}\r\n\r\n <div className=\"pt-6 space-y-6\">{children}</div>\r\n </div>\r\n)\r\n\r\nexport const All{{.PascalCasePageTypePlural}} = async () => {\r\n const { data } = await sanityFetch({ query: all{{.PascalCasePageTypePlural}}Query })\r\n\r\n if (!data || data.length === 0) {\r\n return <OnBoarding />\r\n }\r\n\r\n const list = data as unknown as {{.PascalCasePageTypeSingular}}ListItem[]\r\n\r\n return (\r\n <{{.PascalCasePageTypePlural}}\r\n heading=\"{{.PascalCasePageTypePlural}}\"\r\n subHeading=\"{{.PascalCasePageTypePlural}} populated from your Sanity Studio.\"\r\n >\r\n {list.map((item) => (\r\n <{{.PascalCasePageTypeSingular}}Card key={item._id} item={item} />\r\n ))}\r\n </{{.PascalCasePageTypePlural}}>\r\n )\r\n}\r\n",
"id": "file-1757176137674",
"name": "{{.PascalCasePageTypePlural}}.tsx",
"nodeType": "file"
},
{
"_key": "1756989436755-t8zcsbyvk",
"_type": "treeNode",
"actionFile": true,
"actions": [
{
"_key": "kWkIvI7T6XFbABGiWe5cUQ",
"logic": {
"behaviour": "addMarkerBelowTarget",
"content": "<Link href=\"/{{.LowerCasePageTypePlural}}\" className=\"mr-6 hover:underline\">{{.PascalCasePageTypePlural}}</Link>",
"fallbackOnly": true,
"occurrence": "first",
"target": "<li>"
},
"mark": "",
"title": "PAGETYPE ARCHIVE LINK"
}
],
"children": [],
"code": "import Link from 'next/link'\r\nimport {settingsQuery} from '@/sanity/lib/queries'\r\nimport {sanityFetch} from '@/sanity/lib/live'\r\n\r\nexport default async function Header() {\r\n const {data: settings} = await sanityFetch({\r\n query: settingsQuery,\r\n })\r\n\r\n return (\r\n <header className=\"fixed z-50 h-24 inset-0 bg-white/80 flex items-center backdrop-blur-lg\">\r\n <div className=\"container py-6 px-2 sm:px-6\">\r\n <div className=\"flex items-center justify-between gap-5\">\r\n <Link className=\"flex items-center gap-2\" href=\"/\">\r\n <span className=\"text-lg sm:text-2xl pl-2 font-semibold\">\r\n {settings?.title || 'Sanity + Next.js'}\r\n </span>\r\n </Link>\r\n\r\n <nav>\r\n <ul\r\n role=\"list\"\r\n className=\"flex items-center gap-4 md:gap-6 leading-5 text-xs sm:text-base tracking-tight font-mono\"\r\n >\r\n <li>\r\n <Link href=\"/about\" className=\"hover:underline\">\r\n About\r\n </Link>\r\n </li>\r\n\r\n <li className=\"sm:before:w-[1px] sm:before:bg-gray-200 before:block flex sm:gap-4 md:gap-6\">\r\n <Link\r\n className=\"rounded-full flex gap-4 items-center bg-black hover:bg-blue focus:bg-blue py-2 px-4 justify-center sm:py-3 sm:px-6 text-white transition-colors duration-200\"\r\n href=\"https://github.com/sanity-io/sanity-template-nextjs-clean\"\r\n target=\"_blank\"\r\n rel=\"noopener noreferrer\"\r\n >\r\n <span className=\"whitespace-nowrap\">View on GitHub</span>\r\n <svg\r\n xmlns=\"http://www.w3.org/2000/svg\"\r\n viewBox=\"0 0 24 24\"\r\n fill=\"currentColor\"\r\n className=\"hidden sm:block h-4 sm:h-6\"\r\n >\r\n <path d=\"M12.001 2C6.47598 2 2.00098 6.475 2.00098 12C2.00098 16.425 4.86348 20.1625 8.83848 21.4875C9.33848 21.575 9.52598 21.275 9.52598 21.0125C9.52598 20.775 9.51348 19.9875 9.51348 19.15C7.00098 19.6125 6.35098 18.5375 6.15098 17.975C6.03848 17.6875 5.55098 16.8 5.12598 16.5625C4.77598 16.375 4.27598 15.9125 5.11348 15.9C5.90098 15.8875 6.46348 16.625 6.65098 16.925C7.55098 18.4375 8.98848 18.0125 9.56348 17.75C9.65098 17.1 9.91348 16.6625 10.201 16.4125C7.97598 16.1625 5.65098 15.3 5.65098 11.475C5.65098 10.3875 6.03848 9.4875 6.67598 8.7875C6.57598 8.5375 6.22598 7.5125 6.77598 6.1375C6.77598 6.1375 7.61348 5.875 9.52598 7.1625C10.326 6.9375 11.176 6.825 12.026 6.825C12.876 6.825 13.726 6.9375 14.526 7.1625C16.4385 5.8625 17.276 6.1375 17.276 6.1375C17.826 7.5125 17.476 8.5375 17.376 8.7875C18.0135 9.4875 18.401 10.375 18.401 11.475C18.401 15.3125 16.0635 16.1625 13.8385 16.4125C14.201 16.725 14.5135 17.325 14.5135 18.2625C14.5135 19.6 14.501 20.675 14.501 21.0125C14.501 21.275 14.6885 21.5875 15.1885 21.4875C19.259 20.1133 21.9999 16.2963 22.001 12C22.001 6.475 17.526 2 12.001 2Z\"></path>\r\n </svg>\r\n </Link>\r\n </li>\r\n </ul>\r\n </nav>\r\n </div>\r\n </div>\r\n </header>\r\n )\r\n}\r\n",
"id": "file-1756989436755",
"name": "Header.tsx",
"nodeType": "file"
}
],
"code": "",
"id": "folder-1757939590529",
"name": "components",
"nodeType": "folder"
}
],
"code": "",
"id": "folder-1757940166544",
"name": "app",
"nodeType": "folder"
},
{
"_key": "1757940188389-c6z21ksgn",
"_type": "treeNode",
"actionFile": false,
"actions": [],
"children": [
{
"_key": "1757940203908-vor0y4ba6",
"_type": "treeNode",
"actionFile": false,
"actions": [],
"children": [
{
"_key": "1757064566263-k9lklr230",
"_type": "treeNode",
"actionFile": false,
"actions": [],
"children": [
{
"_key": "20250903-front-queries-file",
"_type": "treeNode",
"actionFile": false,
"actions": [
{
"_key": "kWkIvI7T6XFbABGiWe5cYx",
"logic": {},
"mark": ""
}
],
"children": [],
"code": "import { defineQuery } from \"next-sanity\";\r\nimport { linkFields, linkReference } from \"../queries\";\r\nimport { pageBuilderFields } from \"../queries\";\r\n\r\nexport const listFields = /* groq */ `\r\n _id,\r\n \"name\": coalesce(name, \"Untitled\"),\r\n \"slug\": slug.current,\r\n heading,\r\n subheading\r\n`;\r\n\r\n// List (plural)\r\nexport const all{{.PascalCasePageTypePlural}}Query = defineQuery(`\r\n *[_type == \"{{.LowerCasePageTypeSingular}}\" && defined(slug.current)] | order(_updatedAt desc) {\r\n ${listFields}\r\n }\r\n`);\r\n\r\n// By slug (singular)\r\nexport const {{.LowerCasePageTypeSingular}}BySlugQuery = defineQuery(`\r\n *[_type == \"{{.LowerCasePageTypeSingular}}\" && slug.current == $slug][0]{\r\n _id,\r\n _type,\r\n name,\r\n slug,\r\n heading,\r\n subheading,\r\n \"pageBuilder\": pageBuilder[]{\r\n ${pageBuilderFields}\r\n },\r\n \r\n\r\n },\r\n }\r\n`);\r\n\r\n\r\n// Slugs only\r\nexport const {{.LowerCasePageTypeSingular}}Slugs = defineQuery(`\r\n *[_type == \"{{.LowerCasePageTypeSingular}}\" && defined(slug.current)]{ \"slug\": slug.current }\r\n`);\r\n",
"id": "file-frontend-queries",
"name": "{{.KebabCasePageTypeSingular}}.queries.ts",
"nodeType": "file"
}
],
"code": "",
"id": "folder-1757064566263",
"name": "pagetype-queries",
"nodeType": "folder"
},
{
"_key": "1756919951450-hqircx415",
"_type": "treeNode",
"actionFile": true,
"actions": [
{
"_key": "kWkIvI7T6XFbABGiWe5cdU",
"logic": {
"behaviour": "replaceIfMissing",
"occurrence": "first",
"replacement": "export const linkFields = /* groq */ `",
"requireAbsent": "export const linkFields = /* groq */ `",
"target": "const linkFields = /* groq */ `"
},
"mark": "",
"title": "set linkFields to Export"
},
{
"_key": "kWkIvI7T6XFbABGiWe5ci1",
"logic": {
"behaviour": "replaceIfMissing",
"occurrence": "first",
"replacement": "export const postFields = /* groq */ `",
"requireAbsent": "export const postFields = /* groq */ `",
"target": "const postFields = /* groq */ `"
},
"mark": "",
"title": "Set postFields to Export"
},
{
"_key": "kWkIvI7T6XFbABGiWe5cmY",
"logic": {
"behaviour": "replaceIfMissing",
"occurrence": "first",
"replacement": "export const linkReference = /* groq */ `",
"requireAbsent": "export const linkReference = /* groq */ `",
"target": "const linkReference = /* groq */ `"
},
"mark": "",
"title": "Set linkReference to Export"
},
{
"_key": "kWkIvI7T6XFbABGiWe5cr5",
"logic": {
"behaviour": "addMarkerBelowTarget",
"content": "\"{{.LowerCasePageTypeSingular}}\": {{.LowerCasePageTypeSingular}}->slug.current,",
"occurrence": "first",
"target": "_type == \"link\" => {"
},
"mark": "",
"title": "connect up PageType as linkReference"
},
{
"_key": "kWkIvI7T6XFbABGiWe5cvc",
"logic": {
"behaviour": "insertBeforeInline",
"content": " || _type == \"{{.LowerCasePageTypeSingular}}\"",
"fallbackOnly": true,
"occurrence": "first",
"target": "&& defined(slug.current)] | order(_type asc) {"
},
"mark": "",
"title": "adding PageType to Sitemap"
},
{
"_key": "kWkIvI7T6XFbABGiWe5d09",
"logic": {
"behaviour": "replaceBetween",
"occurrence": "first",
"replacement": "export const pageBuilderFields = /* groq */ `\n ...,\n _type == \"callToAction\" => {\n ${linkFields},\n },\n _type == \"infoSection\" => {\n content[]{\n ...,\n titleDefs[]{\n ...,\n ${linkReference}\n }\n }\n }\n`\n\nexport const getPageQuery = defineQuery(`\n *[_type == 'page' && slug.current == $slug][0]{\n _id,\n _type,\n name,\n slug,\n heading,\n subheading,\n \"pageBuilder\": pageBuilder[]{\n ${pageBuilderFields}\n },\n }\n`)",
"requireAbsent": "export const pageBuilderFields = /* groq */ `",
"targetEnd": "`)",
"targetStart": "export const getPageQuery = defineQuery(`"
},
"mark": "",
"title": "Exportable PageBuilder Fields"
}
],
"children": [],
"code": "import {defineQuery} from 'next-sanity'\r\n\r\nexport const settingsQuery = defineQuery(`*[_type == \"settings\"][0]`)\r\n\r\nexport const postFields = /* groq */ `\r\n _id,\r\n \"status\": select(_originalId in path(\"drafts.**\") => \"draft\", \"published\"),\r\n \"title\": coalesce(title, \"Untitled\"),\r\n \"slug\": slug.current,\r\n excerpt,\r\n coverImage,\r\n \"date\": coalesce(date, _updatedAt),\r\n \"author\": author->{firstName, lastName, picture},\r\n`\r\n\r\nexport const linkReference = /* groq */ `\r\n _type == \"link\" => {\r\n \"page\": page->slug.current,\r\n \"post\": post->slug.current,\r\n }\r\n`\r\n\r\nexport const linkFields = /* groq */ `\r\n link {\r\n ...,\r\n ${linkReference}\r\n }\r\n`\r\n\r\nexport const pageBuilderFields = /* groq */ `\r\n ...,\r\n _type == \"callToAction\" => {\r\n ${linkFields},\r\n },\r\n _type == \"infoSection\" => {\r\n content[]{\r\n ...,\r\n titleDefs[]{\r\n ...,\r\n ${linkReference}\r\n }\r\n }\r\n }\r\n`\r\n\r\nexport const getPageQuery = defineQuery(`\r\n *[_type == 'page' && slug.current == $slug][0]{\r\n _id,\r\n _type,\r\n name,\r\n slug,\r\n heading,\r\n subheading,\r\n \"pageBuilder\": pageBuilder[]{\r\n ${pageBuilderFields}\r\n },\r\n }\r\n`)\r\n\r\n\r\n\r\nexport const sitemapData = defineQuery(`\r\n *[_type == \"page\" || _type == \"post\" && defined(slug.current)] | order(_type asc) {\r\n \"slug\": slug.current,\r\n _type,\r\n _updatedAt,\r\n }\r\n`)\r\n\r\nexport const allPostsQuery = defineQuery(`\r\n *[_type == \"post\" && defined(slug.current)] | order(date desc, _updatedAt desc) {\r\n ${postFields}\r\n }\r\n`)\r\n\r\nexport const morePostsQuery = defineQuery(`\r\n *[_type == \"post\" && _id != $skip && defined(slug.current)] | order(date desc, _updatedAt desc) [0...$limit] {\r\n ${postFields}\r\n }\r\n`)\r\n\r\nexport const postQuery = defineQuery(`\r\n *[_type == \"post\" && slug.current == $slug] [0] {\r\n content[]{\r\n ...,\r\n titleDefs[]{\r\n ...,\r\n ${linkReference}\r\n }\r\n },\r\n ${postFields}\r\n }\r\n`)\r\n\r\nexport const postPagesSlugs = defineQuery(`\r\n *[_type == \"post\" && defined(slug.current)]\r\n {\"slug\": slug.current}\r\n`)\r\n\r\nexport const pagesSlugs = defineQuery(`\r\n *[_type == \"page\" && defined(slug.current)]\r\n {\"slug\": slug.current}\r\n`)\r\n",
"id": "file-1756919951450",
"name": "queries.ts",
"nodeType": "file"
},
{
"_key": "node-utils-linktype-case",
"_type": "treeNode",
"actionFile": true,
"actions": [
{
"_key": "kWkIvI7T6XFbABGiWe5d4g",
"logic": {
"behaviour": "addMarkerAboveTarget",
"content": " case '{{.LowerCasePageTypeSingular}}': {\n const slug = (link as any)?.['{{.LowerCasePageTypeSingular}}']\n return typeof slug === 'string' ? `/{{.LowerCasePageTypePlural}}/${slug}` : null\n }",
"mark": "PAGETYPE ROUTE",
"occurrence": "first",
"target": "default:"
},
"mark": "",
"title": "Setting up routing for PageType"
}
],
"children": [],
"code": "import createImageUrlBuilder from '@sanity/image-url'\r\nimport {Link} from '@/sanity.types'\r\nimport {dataset, projectId, studioUrl} from '@/sanity/lib/api'\r\nimport {createDataAttribute, CreateDataAttributeProps} from 'next-sanity'\r\nimport {getImageDimensions} from '@sanity/asset-utils'\r\n\r\nconst imageBuilder = createImageUrlBuilder({\r\n projectId: projectId || '',\r\n dataset: dataset || '',\r\n})\r\n\r\nexport const urlForImage = (source: any) => {\r\n // Ensure that source image contains a valid reference\r\n if (!source?.asset?._ref) {\r\n return undefined\r\n }\r\n\r\n const imageRef = source?.asset?._ref\r\n const crop = source.crop\r\n\r\n // get the image's og dimensions\r\n const {width, height} = getImageDimensions(imageRef)\r\n\r\n if (Boolean(crop)) {\r\n // compute the cropped image's area\r\n const croppedWidth = Math.floor(width * (1 - (crop.right + crop.left)))\r\n\r\n const croppedHeight = Math.floor(height * (1 - (crop.top + crop.bottom)))\r\n\r\n // compute the cropped image's position\r\n const left = Math.floor(width * crop.left)\r\n const top = Math.floor(height * crop.top)\r\n\r\n // gather into a url\r\n return imageBuilder?.image(source).rect(left, top, croppedWidth, croppedHeight).auto('format')\r\n }\r\n\r\n return imageBuilder?.image(source).auto('format')\r\n}\r\n\r\nexport function resolveOpenGraphImage(image: any, width = 1200, height = 627) {\r\n if (!image) return\r\n const url = urlForImage(image)?.width(1200).height(627).fit('crop').url()\r\n if (!url) return\r\n return {url, alt: image?.alt as string, width, height}\r\n}\r\n\r\n// Depending on the type of link, we need to fetch the corresponding page, post, or URL. Otherwise return null.\r\nexport function linkResolver(link: Link | undefined) {\r\n if (!link) return null\r\n\r\n // If linkType is not set but href is, lets set linkType to \"href\". This comes into play when pasting links into the portable text editor because a link type is not assumed.\r\n if (!link.linkType && link.href) {\r\n link.linkType = 'href'\r\n }\r\n\r\n switch (link.linkType) {\r\n case 'href':\r\n return link.href || null\r\n case 'page':\r\n if (link?.page && typeof link.page === 'string') {\r\n return `/${link.page}`\r\n }\r\n case 'post':\r\n if (link?.post && typeof link.post === 'string') {\r\n return `/posts/${link.post}`\r\n }\r\n\r\n default:\r\n return null\r\n }\r\n}\r\n\r\ntype DataAttributeConfig = CreateDataAttributeProps &\r\n Required<Pick<CreateDataAttributeProps, 'id' | 'type' | 'path'>>\r\n\r\nexport function dataAttr(config: DataAttributeConfig) {\r\n return createDataAttribute({\r\n projectId,\r\n dataset,\r\n baseUrl: studioUrl,\r\n }).combine(config)\r\n}\r\n",
"id": "node-utils-linktype-case",
"name": "utils.ts",
"nodeType": "file"
}
],
"code": "",
"id": "folder-1757940203908",
"name": "lib",
"nodeType": "folder"
}
],
"code": "",
"id": "folder-1757940188389",
"name": "sanity",
"nodeType": "folder"
}
],
"path": "frontend"
},
{
"id": "path-1757177493327-r6hgfxf",
"nodes": [
{
"_key": "1757939529202-zdr6de4t6",
"_type": "treeNode",
"actionFile": false,
"actions": [],
"children": [
{
"_key": "20250903-studio-schema-file",
"_type": "treeNode",
"actionFile": false,
"actions": [
{
"logic": {
"behaviour": null,
"content": null,
"mark": null,
"occurrence": null,
"target": null
},
"mark": "",
"title": null
}
],
"children": [],
"code": "import {defineField, defineType} from 'sanity'\r\nimport { DocumentIcon } from '@sanity/icons'\r\n\r\nexport const {{.LowerCasePageTypeSingular}} = defineType({\r\n name: '{{.LowerCasePageTypeSingular}}',\r\n title: '{{.PascalCasePageTypeSingular}}',\r\n type: 'document',\r\n icon: DocumentIcon,\r\n fields: [\r\n defineField({\r\n name: 'name',\r\n title: 'Name',\r\n type: 'string',\r\n validation: (Rule) => Rule.required(),\r\n }),\r\n\r\n defineField({\r\n name: 'slug',\r\n title: 'Slug',\r\n type: 'slug',\r\n validation: (Rule) => Rule.required(),\r\n options: {\r\n source: 'name',\r\n maxLength: 96,\r\n },\r\n }),\r\n defineField({\r\n name: 'heading',\r\n title: 'Heading',\r\n type: 'string',\r\n validation: (Rule) => Rule.required(),\r\n }),\r\n defineField({\r\n name: 'subheading',\r\n title: 'Subheading',\r\n type: 'string',\r\n }),\r\n defineField({\r\n name: 'pageBuilder',\r\n type: \"pageBuilder\"\r\n }),\r\n ],\r\n})\r\n",
"id": "file-studio-schema",
"name": "{{.KebabCasePageTypeSingular}}.ts",
"nodeType": "file"
}
],
"code": "",
"id": "folder-1757939529202",
"name": "documents",
"nodeType": "folder"
},
{
"_key": "1757939548968-0jrm2e8z9",
"_type": "treeNode",
"actionFile": false,
"actions": [],
"children": [
{
"_key": "node-blockcontent-fixes",
"_type": "treeNode",
"actionFile": true,
"actions": [
{
"logic": {
"behaviour": "addMarkerAboveTarget",
"content": " {title: '{{.PascalCasePageTypeSingular}}', value: '{{.LowerCasePageTypeSingular}}'},",
"mark": "LINKTYPE OPTION",
"occurrence": "first",
"target": "],"
},
"mark": "",
"title": "Adding PageType as a LinkType in BlockContent"
},
{
"logic": {
"behaviour": "addMarkerAboveTarget",
"content": " defineField({\n name: '{{.LowerCasePageTypeSingular}}',\n title: '{{.PascalCasePageTypeSingular}}',\n type: 'reference',\n to: [{type: '{{.LowerCasePageTypeSingular}}'}],\n hidden: ({parent}) => parent?.linkType !== '{{.LowerCasePageTypeSingular}}',\n validation: (Rule) =>\n Rule.custom((value, context: any) => {\n if (context.parent?.linkType === '{{.LowerCasePageTypeSingular}}' && !value) {\n return '{{.PascalCasePageTypeSingular}} reference is required when Link Type is {{.PascalCasePageTypeSingular}}'\n }\n return true\n }),\n }),",
"mark": "PAGETYPE AS FIELD",
"occurrence": "last",
"target": "defineField({"
},
"mark": "",
"title": "EXTRA INTERNAL LINK FIELD"
}
],
"children": [],
"code": "import {defineArrayMember, defineType, defineField} from 'sanity'\r\n\r\n/**\r\n * This is the schema definition for the rich text fields used for\r\n * for this blog studio. When you import it in schemas.js it can be\r\n * reused in other parts of the studio with:\r\n * {\r\n * name: 'someName',\r\n * title: 'Some title',\r\n * type: 'blockContent'\r\n * }\r\n *\r\n * Learn more: https://www.sanity.io/docs/block-content\r\n */\r\nexport const blockContent = defineType({\r\n title: 'Block Content',\r\n name: 'blockContent',\r\n type: 'array',\r\n of: [\r\n defineArrayMember({\r\n type: 'block',\r\n titles: {\r\n annotations: [\r\n {\r\n name: 'link',\r\n type: 'object',\r\n title: 'Link',\r\n fields: [\r\n defineField({\r\n name: 'linkType',\r\n title: 'Link Type',\r\n type: 'string',\r\n initialValue: 'href',\r\n options: {\r\n list: [\r\n {title: 'URL', value: 'href'},\r\n {title: 'Page', value: 'page'},\r\n {title: 'Post', value: 'post'},\r\n ],\r\n layout: 'radio',\r\n },\r\n }),\r\n\r\n defineField({\r\n name: 'href',\r\n title: 'URL',\r\n type: 'url',\r\n hidden: ({parent}) => parent?.linkType !== 'href' && parent?.linkType != null,\r\n validation: (Rule) =>\r\n Rule.custom((value, context: any) => {\r\n if (context.parent?.linkType === 'href' && !value) {\r\n return 'URL is required when Link Type is URL'\r\n }\r\n return true\r\n }),\r\n }),\r\n\r\n defineField({\r\n name: 'page',\r\n title: 'Page',\r\n type: 'reference',\r\n to: [{type: 'page'}],\r\n hidden: ({parent}) => parent?.linkType !== 'page',\r\n validation: (Rule) =>\r\n Rule.custom((value, context: any) => {\r\n if (context.parent?.linkType === 'page' && !value) {\r\n return 'Page reference is required when Link Type is Page'\r\n }\r\n return true\r\n }),\r\n }),\r\n\r\n defineField({\r\n name: 'post',\r\n title: 'Post',\r\n type: 'reference',\r\n to: [{type: 'post'}],\r\n hidden: ({parent}) => parent?.linkType !== 'post',\r\n validation: (Rule) =>\r\n Rule.custom((value, context: any) => {\r\n if (context.parent?.linkType === 'post' && !value) {\r\n return 'Post reference is required when Link Type is Post'\r\n }\r\n return true\r\n }),\r\n }),\r\n\r\n defineField({\r\n name: 'openInNewTab',\r\n title: 'Open in new tab',\r\n type: 'boolean',\r\n initialValue: false,\r\n }),\r\n ],\r\n },\r\n ],\r\n },\r\n }),\r\n ],\r\n})\r\n\r\n",
"id": "node-blockcontent-fixes",
"name": "blockContent.tsx",
"nodeType": "file"
},
{
"_key": "node-link-schema-fixes",
"_type": "treeNode",
"actionFile": true,
"actions": [
{
"logic": {
"behaviour": "addMarkerAboveTarget",
"content": " {title: '{{.PascalCasePageTypeSingular}}', value: '{{.LowerCasePageTypeSingular}}'},",
"mark": "PAGETYPE LINK OPTIONS",
"occurrence": "first",
"target": "],"
},
"mark": "",
"title": "Adding PageType as a LinkType option"
},
{
"logic": {
"behaviour": "addMarkerAboveTarget",
"content": " defineField({\n name: '{{.LowerCasePageTypeSingular}}',\n title: '{{.PascalCasePageTypeSingular}}',\n type: 'reference',\n to: [{type: '{{.LowerCasePageTypeSingular}}'}],\n hidden: ({parent}) => parent?.linkType !== '{{.LowerCasePageTypeSingular}}',\n validation: (Rule) =>\n Rule.custom((value, context: any) => {\n if (context.parent?.linkType === '{{.LowerCasePageTypeSingular}}' && !value) {\n return '{{.PascalCasePageTypeSingular}} reference is required when Link Type is {{.PascalCasePageTypeSingular}}'\n }\n return true\n }),\n }),",
"mark": "PAGETYPE LINK FIELD",
"occurrence": "last",
"target": "defineField({"
},
"mark": "",
"title": "Adding PageType as a Link Field"
}
],
"children": [],
"code": "import {defineField, defineType} from 'sanity'\r\nimport {LinkIcon} from '@sanity/icons'\r\n\r\n/**\r\n * Link schema object. This link object lets the user first select the type of link and then\r\n * then enter the URL, page reference, or post reference - depending on the type selected.\r\n * Learn more: https://www.sanity.io/docs/object-type\r\n */\r\nexport const link = defineType({\r\n name: 'link',\r\n title: 'Link',\r\n type: 'object',\r\n icon: LinkIcon,\r\n fields: [\r\n defineField({\r\n name: 'linkType',\r\n title: 'Link Type',\r\n type: 'string',\r\n initialValue: 'url',\r\n options: {\r\n list: [\r\n {title: 'URL', value: 'href'},\r\n {title: 'Page', value: 'page'},\r\n {title: 'Post', value: 'post'},\r\n ],\r\n layout: 'radio',\r\n },\r\n }),\r\n\r\n // URL\r\n defineField({\r\n name: 'href',\r\n title: 'URL',\r\n type: 'url',\r\n hidden: ({parent}) => parent?.linkType !== 'href',\r\n validation: (Rule) =>\r\n Rule.custom((value, context: any) => {\r\n if (context.parent?.linkType === 'href' && !value) {\r\n return 'URL is required when Link Type is URL'\r\n }\r\n return true\r\n }),\r\n }),\r\n\r\n // Page\r\n defineField({\r\n name: 'page',\r\n title: 'Page',\r\n type: 'reference',\r\n to: [{type: 'page'}],\r\n hidden: ({parent}) => parent?.linkType !== 'page',\r\n validation: (Rule) =>\r\n Rule.custom((value, context: any) => {\r\n if (context.parent?.linkType === 'page' && !value) {\r\n return 'Page reference is required when Link Type is Page'\r\n }\r\n return true\r\n }),\r\n }),\r\n\r\n // Post\r\n defineField({\r\n name: 'post',\r\n title: 'Post',\r\n type: 'reference',\r\n to: [{type: 'post'}],\r\n hidden: ({parent}) => parent?.linkType !== 'post',\r\n validation: (Rule) =>\r\n Rule.custom((value, context: any) => {\r\n if (context.parent?.linkType === 'post' && !value) {\r\n return 'Post reference is required when Link Type is Post'\r\n }\r\n return true\r\n }),\r\n }),\r\n\r\n\r\n defineField({\r\n name: 'openInNewTab',\r\n title: 'Open in new tab',\r\n type: 'boolean',\r\n initialValue: false,\r\n }),\r\n ],\r\n})\r\n\r\n\r\n\r\n",
"id": "node-link-schema-fixes",
"name": "link.ts",
"nodeType": "file"
},
{
"_key": "1757940742288-hed1iz1uc",
"_type": "treeNode",
"actionFile": false,
"actions": [
{
"logic": {
"behaviour": null,
"content": null,
"mark": null,
"occurrence": null,
"target": null
},
"mark": "",
"title": null
}
],
"children": [],
"code": "import { defineType } from \"sanity\";\r\n\r\nexport const pageBuilder = defineType({\r\n name: 'pageBuilder',\r\n title: 'Page builder',\r\n type: 'array',\r\n of: [{type: 'callToAction'}, {type: 'infoSection'}],\r\n options: {\r\n insertMenu: {\r\n // Configure the \"Add Item\" menu to display a thumbnail preview of the content type. https://www.sanity.io/docs/array-type#efb1fe03459d\r\n views: [\r\n {\r\n name: 'grid',\r\n previewImageUrl: (schemaTypeName) =>\r\n `/static/page-builder-thumbnails/${schemaTypeName}.webp`,\r\n },\r\n ],\r\n },\r\n }\r\n})\r\n ",
"id": "file-1757940742288",
"name": "pageBuilder.tsx",
"nodeType": "file"
}
],
"code": "",
"id": "folder-1757939548968",
"name": "objects",
"nodeType": "folder"
},
{
"_key": "20250903-studio-indexer-file",
"_type": "treeNode",
"actionFile": true,
"actions": [
{
"logic": {
"behaviour": "addMarkerBelowTarget",
"content": "import { {{.LowerCasePageTypeSingular}} } from './documents/{{.KebabCasePageTypeSingular}}'",
"mark": "NEXTGEN PAGETYPE IMPORTS",
"occurrence": "first",
"target": "import {post} from './documents/post'"
},
"mark": "",
"title": "Importing PageType to SchemaTypos Index"
},
{
"logic": {
"behaviour": "addMarkerAboveTarget",
"content": "{{.LowerCasePageTypeSingular}},",
"mark": "NEXTGEN PAGETYPES",
"occurrence": "last",
"target": "// Objects"
},
"mark": "",
"title": "Adding DocumentType to SchemaType array"
},
{
"logic": {
"behaviour": "addMarkerAboveTarget",
"content": "pageBuilder,",
"mark": null,
"occurrence": "last",
"target": "]"
},
"mark": "",
"title": "Add Pagebuilder Object to SchemaTypes"
},
{
"logic": {
"behaviour": "addMarkerAboveTarget",
"content": "import {pageBuilder} from './objects/pageBuilder'",
"mark": "",
"occurrence": "first",
"target": "export const schemaTypes = ["
},
"mark": "",
"title": "Import PageBuilder Object to schemaTypes index"
}
],
"children": [],
"code": "import {person} from './documents/person'\r\nimport {page} from './documents/page'\r\nimport {post} from './documents/post'\r\nimport {callToAction} from './objects/callToAction'\r\nimport {infoSection} from './objects/infoSection'\r\nimport {settings} from './singletons/settings'\r\nimport {link} from './objects/link'\r\nimport {blockContent} from './objects/blockContent'\r\n\r\nexport const schemaTypes = [\r\n // Singletons\r\n settings,\r\n // Documents\r\n page,\r\n post,\r\n person,\r\n // Objects\r\n blockContent,\r\n infoSection,\r\n callToAction,\r\n link,\r\n]\r\n",
"id": "file-studio-indexer",
"name": "index.ts",
"nodeType": "file"
}
],
"path": "studio/src/schemaTypes"
}
],
"goals": [
{
"_key": "cb47035fdfc1",
"_type": "goal",
"description": "Creates folder for the route, with dedicated page.tsx for both (index) and [slug]. ",
"fileHints": null,
"howToTips": null,
"title": "Create Files and Folders for the Page Type routing"
},
{
"_key": "4b4596af24a9",
"_type": "goal",
"fileHints": null,
"howToTips": null,
"title": "Create List Component which output posts from new PageType"
},
{
"_key": "6341bfd73a83",
"_type": "goal",
"fileHints": null,
"howToTips": null,
"title": "Create dedicated query file for the new PageType"
},
{
"_key": "68671ad62b9507d53e0a3a9884e2e794",
"description": "Makes the Page Builder UI available in Studio so the new PageType can actually use its blocks.",
"fileHints": [
"studio/src/schemaTypes/index.ts"
],
"howToTips": [
"Add the import near other object imports (use an addMarkerAboveTarget-style insertion; guard with requireAbsent).",
"Include 'pageBuilder' in the schemaTypes array close to other objects (addMarkerAboveTarget; guard with requireAbsent)."
],
"title": "Register PageBuilder object in Studio schema index"
},
{
"_key": "9e1d52f10c59b5414f5f0024ba198fe8",
"description": "Allows Studio to recognize and edit the new document type, which is foundational for all other steps.",
"fileHints": [
"studio/src/schemaTypes/index.ts"
],
"howToTips": [
"Place the import alongside other document imports (addMarkerBelowTarget fits well).",
"Append the type in the documents section of schemaTypes (addMarkerAboveTarget)."
],
"title": "Register PageType in Studio schema index"
},
{
"_key": "422cf6758e0320f0839f79eee12b2a95",
"description": "Turns common fragments into exports so queries across files reuse them cleanly without re-definitions.",
"fileHints": [
"frontend/sanity/lib/queries.ts"
],
"howToTips": [
"Find the primary 'const' definitions and convert to 'export const' (replaceIfMissing).",
"Change only the first canonical definition and guard with requireAbsent to avoid double-exports."
],
"title": "Export shared GROQ fragments (postFields, linkFields, linkReference)"
},
{
"_key": "c03f1f5d8050a64002eec3ea5e031172",
"description": "Centralizes Page Builder selections into a shared fragment and references it in queries to prevent drift.",
"fileHints": [
"frontend/sanity/lib/queries.ts"
],
"howToTips": [
"Introduce an exported fragment near other exports and rebind the query to use it (replaceBetween).",
"Guard with requireAbsent so you donβt redefine an existing export."
],
"title": "Export pageBuilderFields and reuse in getPageQuery"
},
{
"_key": "1dc155c97974905e795375efda018b5a",
"description": "Lets rich-text links resolve this new type by exposing its slug in the mapping used across queries.",
"fileHints": [
"frontend/sanity/lib/queries.ts"
],
"howToTips": [
"Locate the object that maps linkable types to slugs.",
"Insert a new key for the PageType using an addMarkerBelowTarget-style placement near the start of the mapping."
],
"title": "Add PageType to linkReference mapping (rich-text links)"
},
{
"_key": "1632c4adbd71ffd21ff39330e8b72ec9",
"description": "Teaches the client resolver how to build canonical URLs for the new type so internal links work end-to-end.",
"fileHints": [
"frontend/sanity/lib/utils.ts"
],
"howToTips": [
"Find the switch/branch that handles link types.",
"Add a case for the PageType before the default (addMarkerAboveTarget)."
],
"title": "Add PageType route case to linkResolver"
},
{
"_key": "73b826971700d336f33b20f88c1a1410",
"description": "Ensures the new type is included in the sitemap so search engines can discover it.",
"fileHints": [
"frontend/sanity/lib/queries.ts"
],
"howToTips": [
"Identify the allowed _type list in the sitemap filter.",
"Insert the new _type right before the slug guard (insertBeforeInline; fallbackOnly to keep it idempotent)."
],
"title": "Include PageType in sitemapData query"
},
{
"_key": "a89589cca5044b2737be51c5394e55e3",
"description": "Surfaces the new type in the main navigation so users can find its listing page.",
"fileHints": [
"frontend/app/components/Header.tsx"
],
"howToTips": [
"Locate the first nav list item and place the archive link alongside similar internal links.",
"Use addMarkerBelowTarget anchored to the earliest stable <li>.",
"Keep it idempotent (occurrence 'first', fallbackOnly) and skip if an equivalent link exists."
],
"title": "Add archive link to Header nav (idempotent)"
},
{
"_key": "9429adf6c3cb3cb0223106423389ddbd",
"description": "Lets editors choose the new type directly in rich-text link options for portable text blocks.",
"fileHints": [
"studio/src/schemaTypes/objects/blockContent.tsx"
],
"howToTips": [
"Find the editor-facing list of link type options.",
"Add an option entry using addMarkerAboveTarget near the end of the list."
],
"title": "Expose PageType as a link type in blockContent"
},
{
"_key": "b3d0268d929d4d396e20a649f3b91095",
"description": "Shows a reference picker only when the new link type is selected, keeping forms tidy and valid.",
"fileHints": [
"studio/src/schemaTypes/objects/blockContent.tsx"
],
"howToTips": [
"Locate the group of link-related reference fields.",
"Insert a conditional reference field using addMarkerAboveTarget at the final field cluster."
],
"title": "Add conditional PageType reference field in blockContent"
},
{
"_key": "e26034f3c344c79b3b84abf6a47879a8",
"description": "Adds the new type to the generic link object so any schema reusing it can target this type as well.",
"fileHints": [
"studio/src/schemaTypes/objects/link.ts"
],
"howToTips": [
"Find the radio/option list for link types.",
"Add a new entry with addMarkerAboveTarget following the existing structure and ordering."
],
"title": "Expose PageType as a link type in link object"
},
{
"_key": "869ba08bb79f91d33339a90d6352d98b",
"description": "Adds a reference field tied to the new option and required only when that option is chosen.",
"fileHints": [
"studio/src/schemaTypes/objects/link.ts"
],
"howToTips": [
"Identify where other type-specific reference fields are declared.",
"Place the new conditional field alongside them (addMarkerAboveTarget)."
],
"title": "Add conditional PageType reference field in link object"
}
],
"ignoredPatterns": [],
"slug": {
"_type": "slug",
"current": "add-page-type-with-pagebuilder"
},
"slugCurrent": "add-page-type-with-pagebuilder",
"title": "Add Page Type with Pagebuilder",
"variables": [
{
"_type": "variableDefinition",
"description": "This is the name of the \"_type\" we use in Sanity. This dictates a lot of the naming conventions elsewhere.",
"examples": [
"author",
"event",
"product",
"service"
],
"name": "PageTypeSingular",
"priority": 1,
"title": "Name your page type"
},
{
"_type": "variableDefinition",
"description": "This helps cases where we need to pluralize. No worries if it's the same as the singular.",
"examples": [
"authors",
"events",
"products",
"services"
],
"name": "PageTypePlural",
"priority": 2,
"title": "Pluralize your page type"
}
]
}