---
url: /en/docs/api/preview-templates.md
description: >-
  Define custom preview templates in Sveltia CMS to control how content entries
  are displayed.
---

# 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.

::: warning Compatibility Note

Because there is little [Netlify/Decap CMS documentation](https://decapcms.org/docs/customization/#registerpreviewtemplate) 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](/en/docs/api#accessing-the-cms-object):

```js
CMS.registerPreviewTemplate(name, component);
```

### Parameters

* `name` (string, required): The name of the [entry collection](/en/docs/collections/entries), or the name of the file in a [file collection](/en/docs/collections/files) or [singleton collection](/en/docs/collections/singletons), 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](/en/docs/api#writing-react-components) 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](/en/docs/api#using-htm), JSX or `h()` calls — see the [Writing React Components](/en/docs/api#writing-react-components) section for more details.

## Component Props

The component you register receives the following props during render:

* `entry` ([Immutable Map](https://immutable-js.com/docs/v5/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](/en/docs/collections/singletons#referencing-singleton-files). 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](https://immutable-js.com/docs/v5/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](https://immutable-js.com/docs/v5/Map/).

## 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()`](/en/docs/api/preview-styles), 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](https://svelte.dev/) component inside the template, you can instead compile it with `css: "injected"`, either per component with [`<svelte:options>`](https://svelte.dev/docs/svelte/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](https://vuejs.org/) 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](/en/docs/ui/content-editor): 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.

::: code-group

```js [HTM]
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 [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()]
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

::: tip HTM, JSX or `h()`

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

:::

### Basic Entry Preview

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

::: code-group

```js [HTM]
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 [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 [h()]
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:

::: code-group

```js [HTM]
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 [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 [h()]
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:

::: code-group

```js [HTM]
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 [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 [h()]
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`:

::: code-group

```js [HTM]
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 [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 [h()]
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:

::: code-group

```js [HTM]
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 [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 [h()]
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:

::: code-group

```js [HTM]
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 [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 [h()]
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](https://svelte.dev/) or [Vue 3](https://vuejs.org/) 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](#styling-the-preview) for how to get the component’s CSS into the preview.

::: code-group

```js [Svelte]
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 [Vue]
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](/en/showcase?feature=preview-templates).
