Building a block based editor that reuses React components

I have been trying to improve the UX of a site builder by switching to block based editing. The vision here is that a user would simply add pre-defined blocks to the page and then edit the fields on the blocks directly. At the moment, these composable blocks have already been built. They are relatively simple and “dumb” React components that just take in some props - mostly strings - and render some UI (with tailwindcss). Some of them might use React hooks like useState for example in a NotificationBanner block that needs to be dismiss-able. Concretely a simple block might look like this:

const HeroBanner = ({
  title,
  description,
  buttonLabel,
  buttonUrl,
}: HeroBannerProps) => {
  return (
    <div className="flex flex-col">
      <p>This should not be editable</p>
      <h1 className="text-4xl">{title}</h1>
      <p className="text-xl">{description}</p>
      <Button label={buttonLabel} href={buttonUrl} />
    </div>
  )
}

Ideally I can reuse these blocks with minimal effort, plug them into the editor and allow users to edit a field like title or description but not any text that might be intentionally hardcoded in the block.

I believe nodeViews are the way to go here, but honestly I’m not sure how to approach the implementation and was hoping for some pointers or high level ideas. What I’ve tried is basically rebuilding the block, replacing the fields like <p> and <h1> with <input> tags instead and essentially making it a controlled form, however that seems horribly wrong and clunky.

I did see this comment in an older post that seemed like the exact thing I was looking for - reusing React components that are used on the actual built site, inside the editor. Unfortunately I couldn’t find any insights from that thread.

Once again, appreciate any advice or pointers to examples out there; relatively new to prosemirror

In general, the way this would work (using something like @nytimes/react-prosemirror@next, e.g.), would be:

const schema = new Schema({
  nodes: {
    ...,
    heroBanner: {
      content: 'heroTitle heroDescription heroButton',
      group: 'block',
    },
    heroTitle: {
      content: 'text*', // or 'inline*' if you have other inline node types
    },
    heroDescription: {
      content: 'text*',
    },
    heroButton: {
      leaf: true,
      attrs: {
        label: { default: '' },
        url: { default: '' },
      },
    },
  },
}
const HeroBanner = forwardRef(function HeroBanner({ children, nodeProps, ...props }, ref) {
  return (
    <div ref={ref} {...props} className="flex flex-col">
      <p  contentEditable={false}>This should not be editable</p>
      {children}
    </div>
  )
})

const HeroTitle = forwardRef(function HeroTitle({ children, nodeProps, ...props }, ref) {
  return (
    <h1 ref={ref} {...props} className=`text-4xl ${props.className ?? ''}`>
      {children}
    </h1>
  )
})

// ... etc

If you then needed to render these same components in the frontend, you might need some composer:

function FrontendHeroBanner({ title, description, buttonLabel, buttonUrl }) {
  return (
    <HeroBanner>
      <HeroTitle>{title}</HeroTitle>
      <HeroDescription>{title}</HeroDescription>
      <HeroButton nodeProps={{ attrs: { label: buttonLabel, url: buttonUrl } }} />
    </HeroBanner>
  )
} 

Obviously this isn’t exactly just a plug-n-play solution, but it should let you generally share components between your editor and your frontend, if that’s what you need!