CMS

Work in progress: I have not wired every block to Sanity yet. Some have schema and Studio/UI; I add the rest over time.

Example repository: See the example repository with Sanity schemas, block registry, and UI components already wired up. Clone it to explore a full setup or use it as reference while you adapt the steps below to your project.

You can also import the data exported from the example repository to your project. Check the README.md for more details.

The blocks are open source like the rest of Flexnative. This page is just how I set up Sanity: shared objects, the blocks array, registry, render-block, page wiring.

Your folders, plugins, and schema habits will differ. Take the idea, then change paths and types to match your repo. Same point as on the About page: keep the shape obvious so tools (and AI) can reuse catalog examples and rebuild things in your layout.

Right now Flexnative ships Sanity wiring in the open repo. Another CMS might appear later too.

Treat the snippets as a starter layout and adjust them for your Studio and app.

Sanity Typegen: I've used Sanity TypeGen to generate TypeScript types from the schema. The registry and components import types from the generated file (e.g. Blocks, Home in sanity.types.ts). Configure and run typegen in your project per the official documentation.

Shared objects (optional)

Blocks that use CTA, links, etc. reuse shared objects. Example: the cta object. See Shared objects below for the list.

You need to export and include these types in your schema according to your project’s context (e.g. an objects index that re-exports cta and others).

Example: schema-types/objects/cta.ts
import { defineField, defineType } from 'sanity'

// Shared object. Used by blocks that have CTA (e.g. grid-two-columns).
export const cta = defineType({
  name: 'cta',
  title: 'CTA',
  type: 'object',
  fields: [
    defineField({
      name: 'ctaEnabled',
      title: 'Enable CTA',
      type: 'boolean',
      description: 'Enable or disable the call to action button',
      initialValue: false,
    }),
    defineField({
      name: 'text',
      title: 'Text',
      type: 'string',
      hidden: ({ parent }) => parent?.ctaEnabled === false,
    }),
    defineField({
      name: 'link',
      title: 'Link',
      type: 'string',
      hidden: ({ parent }) => parent?.ctaEnabled === false,
    }),
    defineField({
      name: 'variant',
      title: 'Variant',
      type: 'string',
      options: {
        list: [
          { title: 'Default', value: 'default' },
          { title: 'Secondary', value: 'secondary' },
          { title: 'Outline', value: 'outline' },
          { title: 'Ghost', value: 'ghost' },
          { title: 'Link', value: 'link' },
        ],
        layout: 'radio',
      },
      initialValue: 'default',
      hidden: ({ parent }) => parent?.ctaEnabled === false,
    }),
  ],
})

Define a block (e.g. Grid Two Columns)

schema-types/blocks/content/grid-two-columns.ts
import {defineField, defineType} from 'sanity'

// Example content block. Blocks that use shared objects (e.g. CTA)
// import and use defineField with type: 'cta' in fields.
export const contentGridTwoColumnsBlock = defineType({
  name: 'contentGridTwoColumns',
  title: 'Grid Two Columns',
  type: 'object',
  groups: [
    {
      name: 'content',
      title: 'Content',
    },
  ],
  fieldsets: [
    {
      name: 'content',
      title: 'Content',
      options: {
        collapsible: true,
        collapsed: false,
      },
    },
  ],
  fields: [
    defineField({
      name: 'title',
      title: 'Title',
      type: 'string',
      group: 'content',
      fieldset: 'content',
    }),
    defineField({
      name: 'content',
      title: 'Content',
      type: 'text',
      group: 'content',
      fieldset: 'content',
    }),
    defineField({
      name: 'media',
      title: 'Media',
      type: 'image',
      group: 'content',
    }),
  ],
  preview: {
    select: {
      title: 'title',
      content: 'content',
    },
    prepare(selection) {
      const {title, content} = selection
      return {
        title: title ? `Grid Two Columns - ${title}` : 'Grid Two Columns',
        subtitle: content ? content.substring(0, 50) + '...' : '',
      }
    },
  },
})

Define the blocks array (blocks.ts)

This is how I use blocks on a page: an array per category (e.g. content) referenced on the document type. When you add blocks or categories, update this file. The values in of refer to the schema type — if the type doesn’t exist in the project, the Studio will break.

schema-types/blocks/blocks.ts
import {defineType} from 'sanity'

// As you add more blocks or categories, update this file.
// The values in 'of' reference each block's schema name — if the type
// does not exist in the project (not defined and exported in the flow), the Studio breaks.
export const blocks = defineType({
  name: 'blocks',
  title: 'UI Blocks',
  type: 'array',
  options: {
    insertMenu: {
      groups: [
        {
          name: 'content',
          title: 'Content',
          of: ['contentGridTwoColumns'],
        },
        // Add more groups (hero, showcase, etc.) and their blocks here.
      ],
      views: [
        {
          name: 'grid',
          previewImageUrl: (schemaTypeName: string) => `/preview/blocks/${schemaTypeName}.png`,
        },
        {name: 'list'},
      ],
    },
  },
  of: [
    {type: 'contentGridTwoColumns', title: 'Content • Grid Two Columns'},
    // List all blocks you defined here (one entry per type).
  ],
})

Define a page type (e.g. home)

A document type like home uses the blocks array so editors can add blocks in the Studio. The field type: 'blocks' references the blocks schema defined in blocks.ts.

schema-types/pages/home-type.ts
import {defineField, defineType} from 'sanity'

export const homeType = defineType({
  name: 'home',
  title: 'Home',
  type: 'document',
  preview: {
    prepare() {
      return {
        title: 'Home',
      }
    },
  },
  fields: [
    defineField({
      name: 'blocks',
      type: 'blocks',
    }),
  ],
})

Block registry (UI)

When adding a new block, register it here. Import types from the generated file (e.g. Blocks from sanity.types.ts).

components/flx/sanity/blocks/registry.ts
import { Blocks } from "../../sanity.types";

type BlockTypeMap = {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  [K in Blocks[number]["_type"]]: React.ComponentType<any>;
};

export const blockRegistry: BlockTypeMap = {
  // Add all blocks here

  // Example:
  // 'contentBadgeList': Content.BadgeList, 
  // 'contentCarouselMedia': Content.CarouselMedia, ...
};

export type BlockType = keyof typeof blockRegistry;

Render block

import { Blocks } from "../../sanity.types";

import { blockRegistry } from "./registry";

interface RenderBlockProps {
  block: Blocks[number];
}

export function RenderBlock({ block }: Readonly<RenderBlockProps>) {
  if (typeof block === "string" || !block) {
    return null;
  }

  const Component = blockRegistry[block._type];

  if (Component) {
    return <Component block={block} />;
  }

  return null;
}

Blocks component

import { Blocks as BlocksType } from "../../sanity.types";

import { blockRegistry } from "./registry";
import { RenderBlock } from "./render-block";

interface BlocksProps {
  blocks: BlocksType;
}

export function Blocks({ blocks }: Readonly<BlocksProps>) {
  if (!blocks || blocks.length === 0) {
    return null;
  }

  return blocks?.map((block) => {
    if (!blockRegistry[block._type]) {
      return null;
    }

    if (blocks) {
      return (
        <section key={block._key}>
          <RenderBlock key={block._key} block={block} />
        </section>
      );
    }
    return null;
  });
}

Example page

app/(pages)/page.tsx
import { client } from "@/sanity/client";
import { Home } from "@/sanity.types";
import { Blocks } from "@/components/blocks/blocks";
import { cache } from "react";

const getHome = cache(async () => {
  const query = `*[_type == "home"][0]{
    _id,
    seo,
    blocks[]
  }`;

  const data = await client.fetch<Home>(query);

  if (!data) return null;

  return data;
});

export default async function HomePage() {
  const homeData = await getHome();

  return (
    <main className="container mx-auto min-h-screen max-w-6xl p-8 gap-16 flex flex-col">
      <Blocks blocks={homeData?.blocks ?? []} />
    </main>
  );
}

Shared objects

Shared objects are reusable schema types in the Studio. Blocks that need a call-to-action or other shared fields use these. Each object has a Schema (Studio) and a UI component that consumes data from Sanity types. Select an object below.

Schema (Studio)

The CTA object defines the schema for call-to-action fields (text, link, variant). Include it in your schema and export it according to your project’s structure.

schema-types/objects/cta.ts
import { defineField, defineType } from 'sanity'

// Shared object. Used by blocks that have CTA (e.g. grid-two-columns).
export const cta = defineType({
  name: 'cta',
  title: 'CTA',
  type: 'object',
  fields: [
    defineField({
      name: 'ctaEnabled',
      title: 'Enable CTA',
      type: 'boolean',
      description: 'Enable or disable the call to action button',
      initialValue: false,
    }),
    defineField({
      name: 'text',
      title: 'Text',
      type: 'string',
      hidden: ({ parent }) => parent?.ctaEnabled === false,
    }),
    defineField({
      name: 'link',
      title: 'Link',
      type: 'string',
      hidden: ({ parent }) => parent?.ctaEnabled === false,
    }),
    defineField({
      name: 'variant',
      title: 'Variant',
      type: 'string',
      options: {
        list: [
          { title: 'Default', value: 'default' },
          { title: 'Secondary', value: 'secondary' },
          { title: 'Outline', value: 'outline' },
          { title: 'Ghost', value: 'ghost' },
          { title: 'Link', value: 'link' },
        ],
        layout: 'radio',
      },
      initialValue: 'default',
      hidden: ({ parent }) => parent?.ctaEnabled === false,
    }),
  ],
})

CTA component (UI)

Component that consumes CtaType from your generated types (e.g. sanity.types.ts). Use this in blocks that have a CTA field from Sanity.

import { cn } from '@/lib/utils'
import { Cta as CtaType } from '@/sanity.types'

import { Button } from '../ui/button'

export function Cta({
  cta,
  className,
}: Readonly<{ cta: CtaType; className?: string }>) {
  if (!cta?.ctaEnabled) {
    return null
  }

  return (
    <Button
      className={cn('w-fit rounded-full px-5', className)}
      variant={cta?.variant ?? 'default'}
      asChild
    >
      <a
        href={cta?.link ?? ''}
        target={cta?.link ? '_blank' : '_self'}
        rel={cta?.link ? 'noopener noreferrer' : undefined}
        aria-label={cta?.text}
      >
        {cta.text}
        {cta?.link && (
          <span className="sr-only">{`${cta?.text} (opens in new tab)`}</span>
        )}
      </a>
    </Button>
  )
}