In this lesson, we'll unlock the ability to edit content directly in place Stackbit's visual editor. This will result in updated content in the content source.

This is a lesson within the Next.js + Markdown tutorial. Following it requires completing all previous steps. If you're looking for more information on inline or visual editing, please refer to the conceptual guide or the annotations reference docs.

How Inline Editing Works

Inline visual editing is made possible through the use of annotations. These are a combination of data attributes (data-sb-object-id and data-sb-field-path) that enable developers to be completely declarative in what is and is not editable on any given page.

The rest of this lesson will involve annotating components to see inline editing come to life.

See how Stackbit works for a more detailed look at inline editing and annotations.

Annotation Basics

Let's begin with the simplest of examples to demonstrate how annotations work, and then we'll move on to make the site fully editable.

Setting the Object ID

The first step is to note the ID of the content object using a data-sb-object-id attribute. This points Stackbit to the original of the content on the screen. When working with file-based content, the ID is the path to the content file, relative to the root of the project.

Fortunately, this is already provided for us by the content utility in utils/content.js, which sets the _id property on the page object as the path to the content file from the root of the project.

Adding _id to page object

We can use this property to set the object ID for each page in the pages/[[...slug]].jsx page template file.

// pages/[[...slug]].jsx

// ...

export default function ComposablePage({ page }) {
  return (
    // Add object ID to an element that wraps all the page's content
    <div data-sb-object-id={page._id}>...</div>
  )
}

As we'll see, annotations can take advantage of the HTML tree structure. That's why we place the object ID on an HTML element that wraps all the content for the page.

Inspecting the page, you can see the attribute resolve itself for any given page. In this case, we're looking at the home page, which has an object ID of content/pages/index.md (the path to the content file).

data-sb-object-id resolved value

Adding a Dynamic Field Path

The other data attribute we work with is data-sb-field-path. This is used to build a daisy-chained string of properties drilling down to the proper value.

In this case, we want to make the hero's heading editable inline. Given the shape of our home page's content, the field path to the heading would be sections[0].heading, which can be written as the string sections.0.heading.

However, because the page is composable, we can't predict the indexes of the array of sections in which we'll be rendering a hero component. Therefore, we need to build this annotation programmatically while dynamically mapping content to components.

// pages/[[...slug]].jsx

// ...

const componentMap = {
  hero: Hero,
  stats: Stats
}

export default function ComposablePage({ page }) {
  return (
    <div data-sb-object-id={page._id}>
      {page.sections.map((section, idx) => {
        const Component = componentMap[section.type]
        // Add dynamic field path attribute
        return <Component key={idx} {...section} data-sb-field-path={`sections.${idx}`} />
      })}
    </div>
  )
}

Passing Annotations to Components

The example above won't actually render anything new to the DOM. We have to tell the dynamic component (<Component />) to render the attribute.

A common mistake when learning to annotate components is passing an annotation to a component, but not rendering the result.

Let's do that in the hero component.

// components/Hero.jsx

// ...

export const Hero = (props) => {
  return (
    // Render the data attribute provided to the component
    <div className="px-12 py-24 bg-gray-100" data-sb-field-path={props['data-sb-field-path']}>
      ...
    </div>
  )
}

And then we can add the field path for the heading.

import Markdown from 'markdown-to-jsx'
import Image from 'next/image'
import { Button } from './Button.jsx'

// ...

export const Hero = (props) => {
  return (
    <div className="px-12 py-24 bg-gray-100" data-sb-field-path={props['data-sb-field-path']}>
      <div className={`flex mx-auto max-w-6xl gap-12 ${themeClassMap[props.theme] ?? themeClassMap['imgRight']}`}>
        <div className="max-w-xl py-20 mx-auto lg:shrink-0">
          // Add field path for the heading.
          <h1 className="mb-6 text-5xl font-bold" data-sb-field-path=".heading">
            {props.heading}
          </h1>
          // ...
        </div>
      </div>
    </div>
  )
}

Annotations are chained together through HTML inheritance. Because the <h1> element is a descendant of the outer <div> element, we are able to complete the field path to the heading.

⚠️ Notice the leading period in .heading. This is another common mistake. Without this period, the chain of field paths becomes sections.${idx}heading (e.g. sections.0heading, which will result in an error.

Edit the Heading

Now you have what you need to edit the heading inline.

Edit heading inline

Notice that this also changes the content in your content source file (content/pages/index.md).

Make All Content Editable Inline

Let's round out our annotations, noting new and common patterns along the way.

Hero Component Annotations

Here is the updated code for the hero component.

// components/Hero.jsx

// ...

export const Hero = (props) => {
  return (
    <div className="px-12 py-24 bg-gray-100" data-sb-field-path={props['data-sb-field-path']}>
      <div className={`flex mx-auto max-w-6xl gap-12 ${themeClassMap[props.theme] ?? themeClassMap['imgRight']}`}>
        <div className="max-w-xl py-20 mx-auto lg:shrink-0">
          <h1 className="mb-6 text-5xl font-bold" data-sb-field-path=".heading">
            {props.heading}
          </h1>
          <Markdown options={{ forceBlock: true }} className="mb-4 text-lg" data-sb-field-path=".body">
            {props.body}
          </Markdown>
          {props.button && <Button {...props.button} data-sb-field-path=".button" />}
        </div>
        <div className="relative hidden w-full overflow-hidden rounded-md lg:block">
          <Image
            src={props.image.src}
            alt={props.image.alt}
            layout="fill"
            objectFit="cover"
            data-sb-field-path=".image .image.src#@src .image.alt#@alt"
          />
        </div>
      </div>
    </div>
  )
}

Notice the following:

  • We don't have to do anything further with <Markdown />. Fortunately, the component provided by markdown-to-jsx automatically renders the data-sb-field-path attribute.
  • The button field path is passed simply as .button, but we'll have to render that in the button component, which we'll do next.
  • The field path for the image looks a little scarier. This is for direct access to setting the src property, while also being able to select the image object and edit the alt text. Everything after the # denotes an xpath value. Learn more in the full annotations reference.

Button Component Annotations

Next, let's annotate the button component.

// components/Button.jsx

// ...

export const Button = (props) => {
  return (
    <Link href={props.url}>
      <a
        className={`py-3 px-6 inline-block border-2 font-semibold rounded-md transition-all duration-300 ${
          themeClassMap[props.theme] ?? themeClassMap['default']
        }`}
        data-sb-field-path={props['data-sb-field-path']}
      >
        <span data-sb-field-path=".label">{props.label}</span>
      </a>
    </Link>
  )
}

This was is more straightforward, but still a few important concepts to note:

  • The data attribute gets rendered on the anchor, and the label in a span inside the anchor.
  • You could choose to merge the field paths together, but this pattern is nice because it simplifies the code as your button component becomes more complex.
  • The next/link component (<Link /> in this example) does not render data attributes directly to the DOM. A common mistake is to annotate this link component, which will result in annotation errors in your Stackbit dev server logs.

Stats Component Annotations

Last, but not least, let's round out annotations with the stats component.

// components/Stats.jsx

export const Stats = (props) => {
  return (
    <div
      className={`py-24 px-12 text-center ${themeClassMap[props.theme] ?? themeClassMap['primary']}`}
      data-sb-field-path={props['data-sb-field-path']}
    >
      <div className="mx-auto">
        <div className="mb-16">
          <h2 className="mb-4 text-4xl font-bold sm:text-5xl" data-sb-field-path=".heading">
            {props.heading}
          </h2>
          <Markdown options={{ forceBlock: true }} className="sm:text-lg" data-sb-field-path=".body">
            {props.body}
          </Markdown>
        </div>
        <div className="grid max-w-3xl gap-12 mx-auto sm:grid-cols-3" data-sb-field-path=".stats">
          {props.stats.length > 0 &&
            props.stats.map((stat, idx) => <StatItem key={idx} {...stat} data-sb-field-path={`.${idx}`} />)}
        </div>
      </div>
    </div>
  )
}

const StatItem = (props) => {
  return (
    <div data-sb-field-path={props['data-sb-field-path']}>
      <div className="mb-3 text-4xl font-bold sm:text-5xl" data-sb-field-path=".value">
        {props.value}
      </div>
      <div data-sb-field-path=".label">{props.label}</div>
    </div>
  )
}

Notice here that we have two components inside a single file — Stat and StatItem. This is a fairly common pattern among React developers when the child component (StatItem) isn't going to be shared among other components.

Aside from that, the patterns introduced here should already be familiar to you.

Testing Inline Editing

Now you have everything you need to edit content inline on the page. Try it yourself!

Edit various elements

Continue to check the source file(s) as you make changes, so you can see what Stackbit is doing behind the scenes.