Skip to content

Custom Preview Templates ​

A custom preview template allows you to define how content entries are displayed in the CMS preview pane. By registering a custom preview template, you can create a more tailored and user-friendly editing experience for content editors.

Compatibility Note

Because there is little Netlify/Decap CMS documentation on this topic, Sveltia CMS may not be fully compatible with existing preview templates. Our implementation does not include any undocumented component props.

Overview ​

To register a custom preview template, use the registerPreviewTemplate method on the CMS object:

js
CMS.registerPreviewTemplate(name, component);

Parameters ​

  • name (string, required): The name of the entry collection, or the name of the file in a file collection or singleton collection, for which the preview template is being registered. Registering a template with the same name again replaces the previous one.
  • component (React component, required): A React component that defines the preview template. This component receives the entry data as props and should render the preview accordingly. You can write the component with HTM, JSX or h() calls — see the Writing React Components section for more details.

Component Props ​

The component you register receives the following props during render:

  • entry (Immutable Map): Contains the entry data with the following structure:
    js
    {
      data: { ... },      // Data of the locale being previewed
      i18n: {             // Data of the other locales (if i18n is enabled)
        [locale]: {
          data: { ... }
        }
      },
      slug,               // Entry slug, or an empty string for a new entry
      path,               // Entry file path, or an empty string for a new entry
      newRecord,          // Always `false` in a preview
      collection,         // Collection name, or `_singletons` for a singleton
      mediaFiles,         // Array of all the media files in the collection's media folder
    }
  • widgetFor (function): Returns a React element rendering a Svelte field preview for a given field key path. Useful for rendering individual field previews.
  • widgetsFor (function): Returns widget data for a given top-level field name. For List fields, returns an array of Immutable Maps; for Object fields, returns a single Immutable Map; for other fields, returns the raw value. Each Map has:
    js
    {
      data: { ... },              // Raw values of the list item or object
      widgets: { ... }            // Immutable Map of React preview elements keyed by subfield name
    }
    widgets is empty for a list item that is a primitive value, such as a string.
  • getAsset (function): Takes a file path, typically a File or Image field value, including the temporary blob: URL the field holds for a file that hasn’t been saved yet, and returns an asset object with the following properties, or undefined if no matching asset is found:
    • url (string): A URL to display the file in the preview, typically a blob: URL. The public path is used until the blob URL is available.
    • path (string): The public path of the file, or the temporary blob: URL of a file that hasn’t been saved yet.
    • fileObj (File or undefined): The file selected by the user, if the file hasn’t been saved yet.
    • field (undefined): Always undefined. It’s included only for compatibility with Netlify/Decap CMS.
    • toString() (function): Returns url, so Netlify/Decap CMS code like getAsset(path).toString() keeps working. Use optional chaining (getAsset(path)?.toString()) to handle a missing asset.
    • toBase64() (function): Async function that resolves to the file content as a Base64-encoded string, without the data: URL prefix. It rejects with an error if the file can’t be retrieved.
  • getCollection (function): Async function that returns entries from a specified collection, each as an Immutable Map with the same structure as entry, where data holds the default locale’s content. Takes parameters:
    • collectionName (string): Name of the collection to query. Use _singletons for singletons. The Promise is rejected if the collection is not found.
    • slug (string, optional): Entry slug to fetch a specific entry, or the file name in a file collection or the singleton collection; if omitted, returns all entries. If no entry matches, an entry with empty data and slug is returned.
  • fieldsMetaData (Immutable Map): Metadata for each field keyed by the field’s key path, e.g. author for a top-level field, details.author for a field nested in an Object field or authors.0.person for one in a List item. A trailing index is removed, so the subfield of a List field with a single field uses the List field’s key path, e.g. tags instead of tags.0. Useful for accessing related entry data from relation fields.
  • document (Document): The preview pane iframe's Document object. Use this instead of the global document to manipulate the preview DOM.
  • window (Window): The preview pane iframe's Window object. Use this instead of the global window to access the preview window context.

Working with Immutable Data ​

The entry and fieldsMetaData props are Immutable Map objects. Use their methods to safely access nested data:

  • entry.getIn(['data', 'fieldName']) — Access field values
  • entry.get('i18n') — Access internationalization data
  • .toJS() — Convert to a plain JavaScript object

For more information on working with Immutable data structures, see the Immutable.js documentation.

Styling the Preview ​

The preview pane is a sandboxed <iframe> with its own document, so it doesn’t inherit any stylesheets from the admin page — including CSS that your bundler emits for the template or for a component library it uses. Register the styles the template depends on with CMS.registerPreviewStyle(), either as a file path or as a raw CSS string:

js
import css from './preview.css?inline'; // Vite

CMS.registerPreviewStyle('/admin/preview.css');
CMS.registerPreviewStyle(css, { raw: true });

If you render a Svelte component inside the template, you can instead compile it with css: "injected", either per component with <svelte:options> or for the whole bundle with the compilerOptions of @sveltejs/vite-plugin-svelte. Svelte then appends the component’s styles to the document it’s mounted in, which is the preview iframe:

svelte
<svelte:options css="injected" />

Vue has no equivalent: the <style> block of a single-file component always goes through the bundler’s CSS pipeline, which ends up in the admin page. Keep the styles of a Vue preview component in a separate CSS file and register it as shown above.

Linking to the Edit Pane ​

The default preview supports Scroll Synchronization and Click-to-Highlight: scrolling one pane scrolls the other to the same field, and clicking a field in the preview highlights it in the Edit Pane. A custom preview template gets both features by marking its elements with the data-key-path attribute, whose value is the key path of the field the element displays:

  • A top-level field uses its name, e.g. title.
  • A field nested in an Object field adds its name with a dot, e.g. details.author.
  • A field in a List item adds the item’s zero-based index, e.g. sections.0.heading for the heading subfield of the first item in the sections List field.

Clicking a marked element, or anything inside it, highlights the field of the innermost marked element: the Edit Pane expands any collapsed List or Object field containing it, scrolls it into view and focuses it. To let keyboard users do the same, make the element focusable with tabindex="0"; pressing Enter on it highlights the field. An event handler in the template can call event.preventDefault() to stop a click or Enter key press from highlighting a field, e.g. for a button that does something else.

js
html`
  <article>
    <h1 data-key-path="title" tabindex="0">${entry.getIn(['data', 'title'])}</h1>
    <div data-key-path="sections">
      ${entry.getIn(['data', 'sections'])?.map(
        (section, index) => html`
          <section key=${index}>
            <h2 data-key-path="sections.${index}.heading">${section.get('heading')}</h2>
          </section>
        `,
      )}
    </div>
  </article>
`;
jsx
<article>
  <h1 data-key-path="title" tabIndex={0}>
    {entry.getIn(['data', 'title'])}
  </h1>
  <div data-key-path="sections">
    {entry.getIn(['data', 'sections'])?.map((section, index) => (
      <section key={index}>
        <h2 data-key-path={`sections.${index}.heading`}>{section.get('heading')}</h2>
      </section>
    ))}
  </div>
</article>
js
h(
  'article',
  {},
  h('h1', { 'data-key-path': 'title', tabIndex: 0 }, entry.getIn(['data', 'title'])),
  h(
    'div',
    { 'data-key-path': 'sections' },
    entry
      .getIn(['data', 'sections'])
      ?.map((section, index) =>
        h(
          'section',
          { key: index },
          h('h2', { 'data-key-path': `sections.${index}.heading` }, section.get('heading')),
        ),
      ),
  ),
);

The field previews that widgetFor and widgetsFor return are already marked.

Examples ​

HTM, JSX or h()

Each example below comes in three versions: HTM, which runs in the browser as is and is the recommended way; JSX, which requires a build step to transpile it to JavaScript; and h(), which is compatible with Netlify/Decap CMS. The HTM versions use function components, while the others use class components. See Writing React Components for more details.

Basic Entry Preview ​

Display a simple blog post preview with a title and featured image:

js
const PostPreview = ({ entry, widgetFor, getAsset }) => {
  const image = entry.getIn(['data', 'image']);
  const imageAsset = image ? getAsset(image) : null;

  return html`
    <div style="padding: 20px; font-family: sans-serif">
      <h1>${entry.getIn(['data', 'title'])}</h1>
      ${
        imageAsset &&
        html`<img src=${imageAsset.url} alt="Featured" style="max-width: 100%; height: auto" />`
      }
      <div style="margin-top: 20px">${widgetFor('body')}</div>
    </div>
  `;
};

CMS.registerPreviewTemplate('posts', PostPreview);
jsx
export default class PostPreview extends React.Component {
  render() {
    const { entry, widgetFor, getAsset } = this.props;
    const image = entry.getIn(['data', 'image']);
    const imageAsset = image ? getAsset(image) : null;

    return (
      <div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
        <h1>{entry.getIn(['data', 'title'])}</h1>
        {imageAsset && (
          <img src={imageAsset.url} alt="Featured" style={{ maxWidth: '100%', height: 'auto' }} />
        )}
        <div style={{ marginTop: '20px' }}>{widgetFor('body')}</div>
      </div>
    );
  }
}

CMS.registerPreviewTemplate('posts', PostPreview);
js
const PostPreview = createClass({
  render: function () {
    const { entry, widgetFor, getAsset } = this.props;
    const image = entry.getIn(['data', 'image']);
    const imageAsset = image ? getAsset(image) : null;

    return h(
      'div',
      { style: { padding: '20px', fontFamily: 'sans-serif' } },
      h('h1', {}, entry.getIn(['data', 'title'])),
      imageAsset &&
        h('img', {
          src: imageAsset.url,
          alt: 'Featured',
          style: { maxWidth: '100%', height: 'auto' },
        }),
      h('div', { style: { marginTop: '20px' } }, widgetFor('body')),
    );
  },
});

CMS.registerPreviewTemplate('posts', PostPreview);

List Fields ​

Preview a collection entry with a list of authors:

js
const AuthorsPreview = ({ widgetsFor }) => {
  const authors = widgetsFor('authors');

  return html`
    <div style="padding: 20px">
      <h2>Authors</h2>
      ${
        Array.isArray(authors) &&
        authors.map(
          (author, index) => html`
            <div key=${index} style="margin-bottom: 20px; border-bottom: 1px solid #eee">
              <strong>${author.getIn(['data', 'name'])}</strong>
              <p>${author.getIn(['data', 'description'])}</p>
              ${author.getIn(['widgets', 'description'])}
            </div>
          `,
        )
      }
    </div>
  `;
};

CMS.registerPreviewTemplate('team', AuthorsPreview);
jsx
export default class AuthorsPreview extends React.Component {
  render() {
    const { widgetsFor } = this.props;
    const authors = widgetsFor('authors');

    return (
      <div style={{ padding: '20px' }}>
        <h2>Authors</h2>
        {Array.isArray(authors) &&
          authors.map((author, index) => (
            <div key={index} style={{ marginBottom: '20px', borderBottom: '1px solid #eee' }}>
              <strong>{author.getIn(['data', 'name'])}</strong>
              <p>{author.getIn(['data', 'description'])}</p>
              {author.getIn(['widgets', 'description'])}
            </div>
          ))}
      </div>
    );
  }
}

CMS.registerPreviewTemplate('team', AuthorsPreview);
js
const AuthorsPreview = createClass({
  render: function () {
    const { widgetsFor } = this.props;
    const authors = widgetsFor('authors');

    return h(
      'div',
      { style: { padding: '20px' } },
      h('h2', {}, 'Authors'),
      Array.isArray(authors) &&
        authors.map(function (author, index) {
          return h(
            'div',
            { key: index, style: { marginBottom: '20px', borderBottom: '1px solid #eee' } },
            h('strong', {}, author.getIn(['data', 'name'])),
            h('p', {}, author.getIn(['data', 'description'])),
            author.getIn(['widgets', 'description']),
          );
        }),
    );
  },
});

CMS.registerPreviewTemplate('team', AuthorsPreview);

Object Fields ​

Preview settings stored as an object structure:

js
const SiteSettingsPreview = ({ entry, widgetsFor }) => {
  const settings = widgetsFor('site_config');

  return html`
    <div style="padding: 20px; background-color: #f5f5f5; border-radius: 4px">
      <h2>${entry.getIn(['data', 'title'])}</h2>
      <dl>
        <dt>Posts per page:</dt>
        <dd>${settings.getIn(['data', 'posts_per_page'])}</dd>

        <dt>Site tagline:</dt>
        <dd>${settings.getIn(['data', 'tagline'])}</dd>

        <dt>Enable comments:</dt>
        <dd>${settings.getIn(['data', 'enable_comments']) ? 'Yes' : 'No'}</dd>
      </dl>
    </div>
  `;
};

CMS.registerPreviewTemplate('settings', SiteSettingsPreview);
jsx
export default class SiteSettingsPreview extends React.Component {
  render() {
    const { entry, widgetsFor } = this.props;
    const settings = widgetsFor('site_config');

    return (
      <div style={{ padding: '20px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
        <h2>{entry.getIn(['data', 'title'])}</h2>
        <dl>
          <dt>Posts per page:</dt>
          <dd>{settings.getIn(['data', 'posts_per_page'])}</dd>

          <dt>Site tagline:</dt>
          <dd>{settings.getIn(['data', 'tagline'])}</dd>

          <dt>Enable comments:</dt>
          <dd>{settings.getIn(['data', 'enable_comments']) ? 'Yes' : 'No'}</dd>
        </dl>
      </div>
    );
  }
}

CMS.registerPreviewTemplate('settings', SiteSettingsPreview);
js
const SiteSettingsPreview = createClass({
  render: function () {
    const { entry, widgetsFor } = this.props;
    const settings = widgetsFor('site_config');

    return h(
      'div',
      { style: { padding: '20px', backgroundColor: '#f5f5f5', borderRadius: '4px' } },
      h('h2', {}, entry.getIn(['data', 'title'])),
      h(
        'dl',
        {},
        h('dt', {}, 'Posts per page:'),
        h('dd', {}, settings.getIn(['data', 'posts_per_page'])),

        h('dt', {}, 'Site tagline:'),
        h('dd', {}, settings.getIn(['data', 'tagline'])),

        h('dt', {}, 'Enable comments:'),
        h('dd', {}, settings.getIn(['data', 'enable_comments']) ? 'Yes' : 'No'),
      ),
    );
  },
});

CMS.registerPreviewTemplate('settings', SiteSettingsPreview);

Accessing Metadata & Relations ​

Display entry data with related entries fetched via fieldsMetaData:

js
const ArticlePreview = ({ entry, fieldsMetaData, widgetFor }) => {
  const authorSlug = entry.getIn(['data', 'author']);
  const authorData = fieldsMetaData.getIn(['author', 'authors', authorSlug])?.toJS();

  return html`
    <article style="padding: 20px; max-width: 600px">
      <h1>${entry.getIn(['data', 'title'])}</h1>

      ${
        authorData &&
        html`
          <div style="margin-bottom: 20px; font-style: italic; color: #666">
            By <strong>${authorData.name}</strong>
          </div>
        `
      }

      <div style="margin-top: 20px">${widgetFor('content')}</div>

      <footer style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee">
        <small>Published: ${entry.getIn(['data', 'date'])}</small>
      </footer>
    </article>
  `;
};

CMS.registerPreviewTemplate('posts', ArticlePreview);
jsx
export default class ArticlePreview extends React.Component {
  render() {
    const { entry, fieldsMetaData, widgetFor } = this.props;
    const authorSlug = entry.getIn(['data', 'author']);
    const authorData = fieldsMetaData.getIn(['author', 'authors', authorSlug])?.toJS();

    return (
      <article style={{ padding: '20px', maxWidth: '600px' }}>
        <h1>{entry.getIn(['data', 'title'])}</h1>

        {authorData && (
          <div style={{ marginBottom: '20px', fontStyle: 'italic', color: '#666' }}>
            By <strong>{authorData.name}</strong>
          </div>
        )}

        <div style={{ marginTop: '20px' }}>{widgetFor('content')}</div>

        <footer style={{ marginTop: '40px', paddingTop: '20px', borderTop: '1px solid #eee' }}>
          <small>Published: {entry.getIn(['data', 'date'])}</small>
        </footer>
      </article>
    );
  }
}

CMS.registerPreviewTemplate('posts', ArticlePreview);
js
const ArticlePreview = createClass({
  render: function () {
    const { entry, fieldsMetaData, widgetFor } = this.props;
    const authorSlug = entry.getIn(['data', 'author']);
    const authorData = fieldsMetaData.getIn(['author', 'authors', authorSlug])?.toJS();

    return h(
      'article',
      { style: { padding: '20px', maxWidth: '600px' } },
      h('h1', {}, entry.getIn(['data', 'title'])),

      authorData &&
        h(
          'div',
          { style: { marginBottom: '20px', fontStyle: 'italic', color: '#666' } },
          'By ',
          h('strong', {}, authorData.name),
        ),

      h('div', { style: { marginTop: '20px' } }, widgetFor('content')),

      h(
        'footer',
        { style: { marginTop: '40px', paddingTop: '20px', borderTop: '1px solid #eee' } },
        h('small', {}, `Published: ${entry.getIn(['data', 'date'])}`),
      ),
    );
  },
});

CMS.registerPreviewTemplate('posts', ArticlePreview);

Using getAsset ​

Display multiple images from a gallery field with proper asset resolution:

js
const GalleryPreview = ({ entry, getAsset }) => {
  const images = entry.getIn(['data', 'gallery']) ?? [];

  return html`
    <div style="padding: 20px">
      <h2>Image Gallery</h2>
      <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px">
        ${images.map((imagePath, index) => {
          const asset = getAsset(imagePath);

          return asset
            ? html`
                <img
                  key=${index}
                  src=${asset.url}
                  alt="Gallery image ${index + 1}"
                  style="width: 100%; height: auto; border-radius: 4px"
                />
              `
            : null;
        })}
      </div>
    </div>
  `;
};

CMS.registerPreviewTemplate('portfolio', GalleryPreview);
jsx
export default class GalleryPreview extends React.Component {
  render() {
    const { entry, getAsset } = this.props;
    const images = entry.getIn(['data', 'gallery']) ?? [];

    return (
      <div style={{ padding: '20px' }}>
        <h2>Image Gallery</h2>
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '10px' }}>
          {images.map((imagePath, index) => {
            const asset = getAsset(imagePath);
            return asset ? (
              <img
                key={index}
                src={asset.url}
                alt={`Gallery image ${index + 1}`}
                style={{ width: '100%', height: 'auto', borderRadius: '4px' }}
              />
            ) : null;
          })}
        </div>
      </div>
    );
  }
}

CMS.registerPreviewTemplate('portfolio', GalleryPreview);
js
const GalleryPreview = createClass({
  render: function () {
    const { entry, getAsset } = this.props;
    const images = entry.getIn(['data', 'gallery']) ?? [];

    return h(
      'div',
      { style: { padding: '20px' } },
      h('h2', {}, 'Image Gallery'),
      h(
        'div',
        { style: { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '10px' } },
        images.map(function (imagePath, index) {
          const asset = getAsset(imagePath);
          return asset
            ? h('img', {
                key: index,
                src: asset.url,
                alt: `Gallery image ${index + 1}`,
                style: { width: '100%', height: 'auto', borderRadius: '4px' },
              })
            : null;
        }),
      ),
    );
  },
});

CMS.registerPreviewTemplate('portfolio', GalleryPreview);

Using getCollection ​

Display related entries from another collection:

js
const { useEffect, useState } = CMS.React;

const ProductPreview = ({ entry, getCollection }) => {
  const [relatedProducts, setRelatedProducts] = useState([]);

  useEffect(() => {
    const relatedSlugs = entry.getIn(['data', 'related_products']) ?? [];

    // Fetch all products and filter for related ones
    getCollection('products').then((products) => {
      const related = products.filter((product) => {
        const slug = product.get('slug');
        return relatedSlugs.includes(slug);
      });
      setRelatedProducts(related);
    });
  }, []);

  return html`
    <div style="padding: 20px">
      <h1>${entry.getIn(['data', 'title'])}</h1>
      <p>${entry.getIn(['data', 'description'])}</p>

      ${
        relatedProducts.length > 0 &&
        html`
          <div style="margin-top: 30px; border-top: 1px solid #ddd; padding-top: 20px">
            <h3>Related Products</h3>
            <ul>
              ${relatedProducts.map(
                (product, index) => html`<li key=${index}>${product.getIn(['data', 'title'])}</li>`,
              )}
            </ul>
          </div>
        `
      }
    </div>
  `;
};

CMS.registerPreviewTemplate('products', ProductPreview);
jsx
export default class ProductPreview extends React.Component {
  constructor(props) {
    super(props);
    this.state = { relatedProducts: [] };
  }

  componentDidMount() {
    const { getCollection } = this.props;
    const relatedSlugs = this.props.entry.getIn(['data', 'related_products']) ?? [];

    // Fetch all products and filter for related ones
    getCollection('products').then((products) => {
      const related = products.filter((product) => {
        const slug = product.get('slug');
        return relatedSlugs.includes(slug);
      });
      this.setState({ relatedProducts: related });
    });
  }

  render() {
    const { entry } = this.props;
    const { relatedProducts } = this.state;

    return (
      <div style={{ padding: '20px' }}>
        <h1>{entry.getIn(['data', 'title'])}</h1>
        <p>{entry.getIn(['data', 'description'])}</p>

        {relatedProducts.length > 0 && (
          <div style={{ marginTop: '30px', borderTop: '1px solid #ddd', paddingTop: '20px' }}>
            <h3>Related Products</h3>
            <ul>
              {relatedProducts.map((product, index) => (
                <li key={index}>{product.getIn(['data', 'title'])}</li>
              ))}
            </ul>
          </div>
        )}
      </div>
    );
  }
}

CMS.registerPreviewTemplate('products', ProductPreview);
js
const ProductPreview = createClass({
  getInitialState: function () {
    return { relatedProducts: [] };
  },

  componentDidMount: function () {
    const { getCollection } = this.props;
    const relatedSlugs = this.props.entry.getIn(['data', 'related_products']) ?? [];

    getCollection('products').then((products) => {
      const related = products.filter(function (product) {
        const slug = product.get('slug');
        return relatedSlugs.includes(slug);
      });
      this.setState({ relatedProducts: related });
    });
  },

  render: function () {
    const { entry } = this.props;
    const { relatedProducts } = this.state;

    return h(
      'div',
      { style: { padding: '20px' } },
      h('h1', {}, entry.getIn(['data', 'title'])),
      h('p', {}, entry.getIn(['data', 'description'])),

      relatedProducts.length > 0 &&
        h(
          'div',
          { style: { marginTop: '30px', borderTop: '1px solid #ddd', paddingTop: '20px' } },
          h('h3', {}, 'Related Products'),
          h(
            'ul',
            {},
            relatedProducts.map(function (product, index) {
              return h('li', { key: index }, product.getIn(['data', 'title']));
            }),
          ),
        ),
    );
  },
});

CMS.registerPreviewTemplate('products', ProductPreview);

Using Other Frameworks ​

The registered component must be a React component, but it can mount a component written in another framework into the preview document. These examples wrap a Svelte 5 or Vue 3 component, updating its props in place on each render instead of remounting it. Keep the Svelte wrapper in a .svelte.js file so the $state rune compiles. The Vue wrapper renders the component from a render function so that changes to the reactive props are picked up; the rootProps argument of createApp() is not reactive. See Styling the Preview for how to get the component’s CSS into the preview.

js
import { mount, unmount } from 'svelte';
import NewsletterPreview from './newsletter-preview.svelte';

const NewsletterPreviewWrapper = createClass({
  componentDidMount: function () {
    const { document, entry, getAsset } = this.props;

    // `$state` can only initialize a variable, not an object property
    const svelteProps = $state({ entry, getAsset });

    this.svelteProps = svelteProps;
    // Mount into the preview iframe’s document, not the global `document`
    this.svelteComponent = mount(NewsletterPreview, {
      target: document.body,
      props: svelteProps,
    });
  },

  componentDidUpdate: function () {
    const { entry, getAsset } = this.props;

    Object.assign(this.svelteProps, { entry, getAsset });
  },

  componentWillUnmount: function () {
    unmount(this.svelteComponent);
  },

  render: function () {
    // Svelte renders directly into the document, so React has nothing to render
    return null;
  },
});

CMS.registerPreviewTemplate('newsletters', NewsletterPreviewWrapper);
js
import { createApp, h, reactive } from 'vue';
import NewsletterPreview from './NewsletterPreview.vue';

const NewsletterPreviewWrapper = createClass({
  componentDidMount: function () {
    const { document, entry, getAsset } = this.props;

    this.vueProps = reactive({ entry, getAsset });
    this.vueApp = createApp({ render: () => h(NewsletterPreview, this.vueProps) });
    // Mount into the preview iframe’s document, not the global `document`
    this.vueApp.mount(document.body);
  },

  componentDidUpdate: function () {
    const { entry, getAsset } = this.props;

    Object.assign(this.vueProps, { entry, getAsset });
  },

  componentWillUnmount: function () {
    this.vueApp.unmount();
  },

  render: function () {
    // Vue renders directly into the document, so React has nothing to render
    return null;
  },
});

CMS.registerPreviewTemplate('newsletters', NewsletterPreviewWrapper);

Showcase ​

Real-world examples of custom preview templates can be found in our showcase.