Skip to main content

Annotations at Scale

Annotations at scale
We provide the most unobtrusive solution in our domain, but annotating your components at scale is a notable challenge. Here’s a scalable solution.

Over the past year, we’ve emphasized easy adoption - from tutorials and examples for multiple web frameworks & content sources to type-safe configuration and Bring Your Own Content Source.

However, there’s a question we often hear from our customers:

How do we add annotations across a large website without touching (and then maintaining) our code over hundreds of files?

Annotations are the data-sb-* data attributes that you mark your page elements with to let Stackbit’s visual editor know which elements are associated with content objects, sub-objects, or specific fields, e.g.

import Link from 'next/link';

export const Button = (props) => {
  return (
    <Link
      href={props.url}
      className={...}
      data-sb-object-id={props.id}
    >
      <span data-sb-field-path="label">{props.label}</span>
    </Link>
  );
};

While not a necessity, annotations enable multiple features for content editors: inline editing, saving components as presets, modifying lists, and styling controls.

As a general rule, our advice is always not to try and immediately overdo it with annotations. Instead:

  1. Start by mapping page URLs to content objects (via the simple urlPath attribute or the more powerful siteMap function). This is enough for letting content editors navigate through the sitemap and edit page fields side-by-side with the live preview.

  2. Stop. Let editors work and see the value. Ask for feedback.

  3. Then, start annotating major components - those your content editors would benefit the most from having quick access to or saving as preset.

  4. Stop again. Let editors work. Get feedback.

  5. Lastly, consider annotating the rest of your components and specific fields that are frequently modified (or those you’d like to enable styling controls for).

But still, the question remains: given the value annotations provide, how do I add these to my real-life project, perhaps with hundreds of components, from the lowly button to the most complex component?

Below is a helpful solution for big projects, with full conceptual and implementation details.

A complete example based on this solution is available here. You can easily run it locally.

Annotations at Scale

First, we should identify the common points in the codebase where annotations can be injected to serve many components.

Fortunately, serious websites tend to have a common trait that we keep seeing in our customers:

Flexible Pages & Dynamic Component Selection

To make page composition flexible, the content model for pages allows for a lot of choice in which child components can be placed where.

Consider a full-page layout: its main content may be composed of a list of sections (full-width components stacked vertically) of many different kinds.

These section types typically also accept a variety of child components: badges, images, videos, and call-to-action elements (buttons, links, forms, …). This provides a lot of freedom for content teams, though there are always constraints.

This design approach is also known as Atomic Design (though real-life projects typically don’t religiously follow that terminology).

Implementing the rendering code for pages with such an open content model isn’t feasible via hard-coded page layouts. Instead, many places in code call a centralized helper function or consult a mapping object for selecting (or resolving) the appropriate component for each child content object. e.g., a HeroSection content object is matched with a HeroSection component.

With this approach, parent components don’t even know the type of their child components. Instead, they get the appropriate components for the actual content.

Here’s a simplistic example from one of our tutorials:

import { Hero } from '../components/Hero.jsx';
import { Stats } from '../components/Stats.jsx';

...

const componentMap = {
    hero: Hero,
    stats: Stats
        ...more components mapped by content type
};

export default function ComposablePage({ page }) {
    return (
        <div>
            {(page.sections || []).map((section, idx) => {
                const Component = componentMap[section.type];
                return <Component key={idx} {...section} />;
            })}
        </div>
    );
}

(for more on this pattern, read up on Content Driven Development or see a more real-life example)

Since there’s a centralized point of resolving components based on the actual content, we could use that point to wrap each concrete component with a wrapper element having the proper annotation!

For that to work, though, first we must ensure that the appropriate content object ID or field path is found in the content so that that wrapper can fetch the needed annotation value from the content object passed to it.

Tweaking Your Content Loading Code (If Needed)

Depending on the CMS you use, and how you load data from it, this may be super-easy (or even require no further work) or need a bit more one-time effort.

The simple case

In some CMSs (notably with Contentful), each content object is stand-alone and has a unique ID. There are no sub-objects without an ID, only references between objects. In that case, you only need to ensure that the content’s unique ID is available as a property of each object you load. In Contentful, for example, this is the sys.id property.

Adding the field path to nested objects

Other content sources (e.g., Sanity or our built-in Git support) support fully nested sub-objects with no unique ID.

In this case, after loading content objects, traverse each object to add a field-path property to all levels.

Here is an example code to do just that, adapted from our complete example (trigger warning: recursive code ahead!).

const isDev = process.env.NODE_ENV === 'development';

// Add annotation data to a content object and its nested children.
function annotateContentObject(o: any, prefix = '', depth = 0) {
  if (!isDev || !o || typeof o !== 'object' || !o.type) return;

  const depthPrefix = '--'.repeat(depth);
  if (depth === 0) {
    o['data-sb-object-id'] = o.id;
    console.log('Aded object ID:', depthPrefix, o.id);
  } else {
    o['data-sb-field-path'] = prefix;
    console.log('Added field path:', depthPrefix, prefix);
  }

  Object.entries(o).forEach(([k, v]) => {
    if (v && typeof v === 'object') {
      const fieldPrefix = (prefix ? prefix + '.' : '') + k;
      if (Array.isArray(v)) {
        v.forEach((e, idx) => {
          const elementPrefix = fieldPrefix + '.' + idx;
          annotateContentObject(e, elementPrefix, depth + 1);
        });
      } else {
        annotateContentObject(v, fieldPrefix, depth + 1);
      }
    }
  });
}

Here’s a snippet from the console logs this function outputs:

Each sub-object in the content now has its Stackbit-ready full field path available as an attribute of the object!

Note: you only need these IDs (and annotations in general) in development mode, not in production. This is simple to enforce when all relevant logic is encapsulated in very few places in your codebase.

Wrapping Your Components With Annotations

Now, to the final part:

Whenever a component is dynamically resolved by content type, don’t just instantiate the appropriate component but wrap it with a tag holding the annotation itself. In order not to affect the visuals of the page, I’ve picked a semantic-only data element as the tag.

Here’s the heart of that logic, adapted from the example site.

// Mapping of content type to actual component
const components = {
  ContactSection: dynamic(() => import('./sections/ContactSection')),
  CtaSection: dynamic(() => import('./sections/CtaSection')),
  // ...
  PageLayout: dynamic(() => import('./layouts/PageLayout')),
  ProjectLayout: dynamic(() => import('./layouts/ProjectLayout'))
  // ...
};

// Resolve & instantiate the appropriate component by content type
export const DynamicComponent: React.FC<ContentObject> = (props) => {
  const Component = components[props.type] as ComponentType;
  return (
    // Added: wrap the actual component with <Annotated>
    <Annotated content={props}>
      <Component {...props} />
    </Annotated>
  );
};

/* Added code below */

type AnnotatedProps = PropsWithChildren & { content: ContentObject };

// Used both by DynamicComponent() above and where components
// are explicitly created (i.e. component type is hard-coded)
export const Annotated: React.FC<AnnotatedProps> = (props) => {
  const { children, content } = props;
  const baseResult = <>{children}</>;
  if (!isDev) return baseResult; // Don't affect production

  // Detect which annotation property exists in the content object
  const annotationKey = ['data-sb-object-id', 'data-sb-field-path'].find((e) => !!content[e]);
  if (!annotationKey) return baseResult; // No annotation found

  let annotationValue = content[annotationKey];
  // Append an #<xpath> to field-paths, telling Stackbit to highlight
  // the child element rather than the <data> tag itself.
  if (annotationKey === 'data-sb-field-path') annotationValue += '#*[1]';

  const annotation = { [annotationKey]: annotationValue };
  return <data {...annotation}>{children}</data>;
};

What’s happening here?

The DynamicComponent helper component is not directly related to Stackbit. Instead, it’s called in all places in code where a child component needs to be created based on actual content fetched from CMS (e.g., here).

The new part is the use of Annotated:

  • When building for production, it does nothing but return the child component.

  • It looks for an annotation attribute in the content object - the attributes we added to the content in the previous stage.

  • If found, the child is wrapped with a <data> element. This element by itself does nothing but hold the actual annotation data attribute: either data-sb-object-id or data-sb-field-path (reference).

The result is that all dynamically-resolved components are now auto-annotated. For example, all section components in the example’s homepage are now wrapped with the appropriate annotation:

The complete example code adds robust error handling and helper functions for explicitly annotating components and specific fields where DynamicComponent is not used (e.g., here and here), while still benefiting from type-safety as possible.

Wrapping Up

This approach requires more careful execution than sprinkling data attributes over all components. On the other hand, you don’t need that sprinkling!

If you want to consider how to apply this approach to your codebase, talk to us.