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:
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 orh()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 }widgetsis 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 temporaryblob:URL the field holds for a file that hasn’t been saved yet, and returns an asset object with the following properties, orundefinedif no matching asset is found:url(string): A URL to display the file in the preview, typically ablob:URL. The public path is used until the blob URL is available.path(string): The public path of the file, or the temporaryblob:URL of a file that hasn’t been saved yet.fileObj(Fileorundefined): The file selected by the user, if the file hasn’t been saved yet.field(undefined): Alwaysundefined. It’s included only for compatibility with Netlify/Decap CMS.toString()(function): Returnsurl, so Netlify/Decap CMS code likegetAsset(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 thedata: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 asentry, wheredataholds the default locale’s content. Takes parameters:collectionName(string): Name of the collection to query. Use_singletonsfor 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 emptydataandslugis returned.
fieldsMetaData(Immutable Map): Metadata for each field keyed by the field’s key path, e.g.authorfor a top-level field,details.authorfor a field nested in an Object field orauthors.0.personfor one in a List item. A trailing index is removed, so the subfield of a List field with a singlefielduses the List field’s key path, e.g.tagsinstead oftags.0. Useful for accessing related entry data from relation fields.document(Document): The preview pane iframe's Document object. Use this instead of the globaldocumentto manipulate the preview DOM.window(Window): The preview pane iframe's Window object. Use this instead of the globalwindowto 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 valuesentry.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:
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: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.headingfor theheadingsubfield of the first item in thesectionsList 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.
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>
`;<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>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:
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);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);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:
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);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);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:
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);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);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:
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);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);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:
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);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);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:
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);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);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.
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);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.