Embedding Unlayer is straightforward. The real agenda is to make sure that it performs well, scales cleanly, and holds up in production.
If you are a developer who’s past the initial setup and is focused on getting Unlayer right in your live app, this is the guide you will want to keep in your pocket.
This guide covers all Unlayer best practices for developers, including how to integrate the builder, optimize performance, manage multiple editor instances and content types, customize and extend functionality, troubleshoot integration issues, and use the developer console effectively.
Let’s dive into them one by one.
1. Initial Integration & Setup
✅ Choose the right integration method
Use the core JavaScript SDK
(unlayer.init)
if you are building without a frontend framework.
<script src="https://editor.unlayer.com/embed.js"></script>
For framework-specific integration, install the official wrapper from npm or yarn.
1. For React:npm install @unlayer/react
oryarn add @unlayer/react
2. For Angular:
npm install @unlayer/angular
oryarn add @unlayer/angular
3. For Vue:
npm install @unlayer/vue
oryarn add @unlayer/vue
Why: Wrappers handle mounting, unmounting, or reactivity automatically. The SDK provides full control, but you have to manage the component lifecycle manually.
✅ Always set the projectId
when initializing the editor
unlayer.init({
id: 'editor-container', // ID of the container created in previous step
projectId: 1234, // Add your project ID here
displayMode: 'email', // Can be 'email', 'web' or 'popup'
});
Why: Setting the projectId
will automatically load your project configuration and entitlements from the Unlayer dashboard, including themes, tools, permissions, and saved appearance settings. Otherwise, you have to configure it all manually in your code.
✅ Set up builder appearance settings during initialization
appearance: {
theme: 'light',
panels: {
tools: { dock: 'left' }
}
}
Why: This controls colors, layout, branding, and tool panel behavior, ensuring the builder UI matches your app from the start.
✅ Load custom CSS/JS before unlayer.init()
using customCSS
and customJS
Custom CSS
Add custom CSS via string or URL
unlayer.init({
customCSS: 'https://example.com/custom-styles.css',
});
Or, pass CSS source code like this:
unlayer.init({
customCSS: [
"
body {
background-color: yellow;
}
"
]
});
Custom JS
Pass custom JS to the builder using the customJS
property during initialization.
unlayer.init({
customJS: '//cdn.muicss.com/mui-0.9.28/js/mui.min.js',
});
Or, pass the JS source code like this:
unlayer.init({
customJS: [
"
console.log('I am custom JS!');
"
]
});
Why: Any custom styles or tools must be loaded before the builder initialization; otherwise, it will cause rendering issues.
📄 Documentation: Custom JS/CSS
✅ Set the displayMode
based on the type of your content
unlayer.init({
id: 'editor-container',
projectId: 1234,
displayMode: 'email' // Can be 'email', 'web', or 'popup'
});
Why: The unlayer builder may default to email behavior, which might not be your use case. Set displayMode
to optimize the builder according to your content type.
2. Optimizing Editor's Performance
✅ Lazy-load the builder
const script = document.createElement('script');
script.src = 'https://editor.unlayer.com/embed.js';
script.onload = () => {
unlayer.init({ id: 'editor', projectId: 1234 });
};
document.head.appendChild(script);
Why: Loading Unlayer on demand will prevent it from blocking your application’s initial load and will keep the bundle size smaller.
✅ Reduce the initial payload
unlayer.init({
id: 'editor',
projectId: 1234,
tools: {
image: { enabled: true },
video: { enabled: false }
},
fonts: [],
design: { body: { rows: [] } }
});
Why: Avoiding loading fonts, tools, or prefilled designs unnecessarily during initialization keeps the builder fast and responsive.
✅ Debounce save operations
unlayer.init({
features: {
textEditor: {
triggerChangeWhileEditing: true,
debounce: 100, // Trigger changes every 100ms during editing
},
},
});
Why: Avoiding triggering save requests on a small change reduces backend load and keeps the UI fast and responsive.
✅ Avoid unnecessary mounting/unmounting of the builder
Why: Calling unlayer.init()
or unlayer.destroy()
repeatedly slows down your app and causes performance issues. Instead, keep the builder mounted and toggle visibility rather than tearing it down.
<!-- HTML -->
<div id="editor-container" style="display: none;"></div>
<button onclick="toggleEditor()">Toggle Editor</button>
<script>
let isEditorInitialized = false;
function toggleEditor() {
const editor = document.getElementById('editor-container');
if (!isEditorInitialized) {
unlayer.init({
id: 'editor-container',
projectId: 1234
});
isEditorInitialized = true;
editor.style.display = 'block';
} else {
// Just toggle visibility instead of destroying
editor.style.display = editor.style.display === 'none' ? 'block' : 'none';
}
}
</script>
✅ Use callbacks wisely
Why: Use callbacks like onLoad
, onDesignLoad
, or onExport
only when necessary and keep the logic lightweight.
For example, callbacks, especially onExport
, contain heavy or blocking logic that can slow down the builder or delay actions. So, keeping them minimal will maintain a smooth and responsive editor experience.
unlayer.init({
id: 'editor',
projectId: 1234,
onExport: function (data) {
// ✅ Lightweight logic only — non-blocking, async
fetch('/api/save-html', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ html: data.html })
}).then(() => {
console.log('Export saved.');
});
// ❌ Avoid: heavy computation or synchronous blocking code here
}
});
✅ Optimize the usage of assets
Why: Unlayer’s built-in CDN (used with default storage or S3 integration) compresses and optimizes uploaded images for better performance. However, we recommend starting with lightweight assets like compressed images or SVGs to avoid slowdowns during upload, rendering, and loading large templates.
Note: If you're using a custom file storage solution like your own cloud, you must manually handle image uploads.
unlayer.registerCallback('image', function (file, done) {
var data = new FormData();
data.append('file', file.attachments[0]);
fetch('/uploads', {
method: 'POST',
headers: {
Accept: 'application/json',
},
body: data,
})
.then((response) => {
if (response.status >= 200 && response.status < 300) {
return response.json();
} else {
throw new Error(response.statusText);
}
})
.then((data) => {
done({ progress: 100, url: data.filelink });
})
.catch((error) => {
console.error('Upload failed:', error);
});
});
✅ Lock the builder version
unlayer.init({
version: '1.63.2', // Lock to a specific version
});
Why: Lock the builder to a specific version to prevent unexpected changes when Unlayer releases any updates and to keep your integration stable.
3. Managing Builder Instances
✅ Use a separate container for each builder instance
unlayer.init({
id: 'email-builder',
displayMode: 'email',
});
unlayer.init({
id: 'page-builder',
displayMode: 'web',
});
Why: Initializing the builder in its unique DOM element to avoid tool overlaps, broken layouts, and event binding conflicts.
✅ Destroy builder instance before re-initializing
unlayer.destroy(); // Clean up existing instance
unlayer.init({
id: 'builder-container',
displayMode: 'web',
});
Why: Whether you're re-initializing the builder or switching between content types, always make sure to destroy the previous instance first. This helps avoid unexpected UI issues, memory leaks, and duplicate event listeners.
✅ Switch builder mode dynamically
function switchBuilder(type) {
unlayer.destroy(); // Clean up previous instance
unlayer.init({
id: 'builder-container',
projectId: 1234,
displayMode: type, // 'email' | 'web' | 'popup'
});
}
Why: Reusing the same container for different content types while switching displayMode
prevents DOM clutter, reduces memory usage, and ensures that every builder loads with the right tools and configuration.
✅ Initialize the builder only when the container is visible
function showTabAndInitBuilder() {
const tab = document.getElementById('builder-tab');
tab.style.display = 'block'; // Make container visible
unlayer.init({
id: 'builder-container',
projectId: 1234,
displayMode: 'email',
});
}
Why: Initializing the builder inside a hidden container may cause the canvas and panels to render incorrectly or break.
4. Supporting Multiple Content Types
✅ Load pre-configured templates based on content type
function loadTemplateFor(type) {
const template = getTemplateByType(type); // Your logic to fetch the design JSON
unlayer.loadDesign(template);
}
Why: Use the load and save design feature to pull in the right template dynamically. It gives your users a head start with a ready-made layout, saving time and keeping designs consistent.
✅ Configure export endpoints per content type and format
unlayer.exportHtml((data) => {
const { design, html } = data;
const exportType = getExportType(); // e.g., 'email', 'page', 'popup'
saveExport(exportType, {
html,
design,
text: unlayer.exportText(), // Optional plain text
});
});
Why: Different types of content require different export formats. Web pages usually require only HTML, but emails need both HTML and plain text for compatibility across email clients. Configuring the export logic for every content type ensures seamless integration with downstream systems.
📄 Documentation: Export Designs
✅ Restrict builder access with project entitlements
Why: This prevents users from accessing unsupported builder modes, keeps the UI clean, and reduces implementation errors.
Note: Project entitlements are managed via Unlayer’s dashboard. Contact us to enable them.
5. Customization & Extensibility
✅ Register custom tool to add app-specific functionality
unlayer.registerTool({
name: 'product-listing',
label: 'Product Listing',
icon: 'fa-shopping-cart',
supportedDisplayModes: ['email', 'web'],
properties: {
name: {
label: 'Title',
defaultValue: 'Featured Products'
}
},
values: {
name: 'Featured Products'
},
render: function () {
return `<div>{{ dynamicProductHTML }}</div>`;
}
})
Why: Registering custom tools using unlayer.registerTool()
exposes your app’s features (like product listings or widgets) as drag-and-drop blocks inside the builder so that the end-users can add them to their design without writing a single line of code.
📄 Learn How to Create a Custom Tool ➞
✅ Use custom property editors and advanced options for logic/UI control
unlayer.registerPropertyEditor({
name: 'custom-color-picker',
layout: 'bottom',
Widget: CustomColorPickerComponent
});
properties: {
size: {
label: 'Size',
defaultValue: 'medium',
options: ['small', 'medium', 'large'],
condition: 'this.showSizeOption == true'
}
}
Why: Custom property editors let you create easy-to-use input for end-users, such as sliders or styled dropdowns. The advanced options help you control logic, such as setting defaults, showing/hiding fields, etc., based on how users configure the tool.
📄 Docs: Built-in Property Editors, Advanced Options
✅ Leverage custom blocks for reusable, brand-specific content
unlayer.registerBlock({
name: 'brand-header',
label: 'Brand Header',
category: 'Brand Elements',
design: {
body: {
rows: [
{
columns: [
{
contents: [
{
type: 'text',
values: {
text: 'Welcome to Our Newsletter',
textAlign: 'center',
fontSize: '24px'
}
}
]
}
]
}
]
}
}
});
Why: With custom blocks, you can create reusable content sections such as branded headers, footers, etc., so that your end-users just drag-and-drop the blocks instead of building them from scratch every time, keeping designs consistent and on-brand.
📄 Documentation: Custom Blocks
✅ Enable dynamic content for personalization
unlayer.init({
mergeTags: {
first_name: { name: 'First Name', value: '{{first_name}}' },
user_type: { name: 'User Type', value: '{{user_type}}' }
},
designTags: {
primary_color: '#FF6600',
banner_text: 'Welcome Back!'
}
});
{
"displayCondition": {
"attribute": "user_type",
"operator": "equals",
"value": "premium"
}
}
Why: Enable personalization by defining merge tags, design tags, and display conditions using your app’s user data. This lets your end-users add dynamic values (names, segments, etc.), change design elements, and show/hide content without any code.
📄 Documentation: Merge Tags, Design Tags, Display Conditions, Dynamic Images
6. Troubleshooting Framework-Specific Integration Issues
React, Angular, and Vue handle component rendering and lifecycles differently, and you might run into issues like repeated builder initialization, design loading errors, broken AMP blocks, or missing custom tools.
To help you avoid these issues before they occur (and fix them if they do), we’ve created framework-specific troubleshooting guides:
📄 React: Troubleshooting Common Issues When Embedding Unlayer in React
📄 Angular: Angular Editor Development: Common Pitfalls & How to Fix Them
📄 Vue: 6 Common Vue Email Builder Issues and How to Avoid Them
7. Using the Developer Hub & CLI for Debugging and Control
✅ Use the Dev Hub to test and preview designs
Developer Hub → Load Design → Export → Switch modes (email/page/popup) |
Why: This lets you test design JSON, preview HTML/PDF/image/plain-text exports, and validate builder state across display modes without touching the live codebase.
✅ Use console.log to debug lifecycle events
unlayer.init({
id: 'editor',
onLoad: () => console.log('✔️ Editor loaded'),
onDesignLoad: d => console.log('🌀 Design loaded:', d),
onExport: data => console.log('📤 Export HTML snippet:', data.html.slice(0,100)),
});
Why: Capturing events and payloads in real time ensures that callbacks are behaving as expected and flags issues like failed integrations or missing design data very quickly.
✅ Inspect the .exportHtml()
payload directly
unlayer.exportHtml(data => {
console.log('Full design JSON:', data.design);
console.log('HTML output preview:', data.html.slice(0,200));
});
Why: Reviewing the raw export data helps you catch issues like broken layouts, missing merge tags, incorrect conditions, etc, before the design reaches the backend or rendering pipeline.
✅ Automate design exports via CLI
# Example CLI workflow for batch exporting
unlayer-cli export \
--design ./email_template.json \
--output ./exports \
--formats html,pdf,image
Why: This helps catch formatting issues, template bugs, or regressions early, and is especially helpful if you are managing large-scale or multi-tenant design systems.
8. Content Safety & Audit
✅ Enable content audit rules
unlayer.init({
features: {
contentAudit: {
rules: ['requiredPreheader', 'mergeTagValidation']
}
}
});
Why: To automatically check required elements such as preheaders and ensure that all merge tags are appropriately filled, helping catch content issues before export.
✅ Set preheader text & metadata
unlayer.init({
design: {
metadata: {
preheader: 'Latest updates and offers inside',
campaign_id: 'welcome_series'
}
}
});
Why: To improve how your emails appear in the inbox preview and to track and analyze campaign performance.
✅ Validate merge tags on export
unlayer.registerCallback('export', (data, done) => {
if (data.html.includes('{{')) {
alert('Merge tags are missing values.');
}
done();
});
Why: This catches personalization errors like {{first_name}}
before they reach end-users, preventing broken or unprofessional emails.
9. Security & Compliance (If Applicable)
✅ Use SOC 2-compliant file storage
unlayer.init({
fileUpload: {
url: 'https://your-s3-upload-endpoint.com'
}
});
Why: To ensure that uploaded files are securely handled using services such as Amazon S3 that are SOC 2 compliant and meet all the enterprise-grade security standards.
✅ Secure file uploads with signed URLs
unlayer.registerCallback('image', (file, done) => {
fetch('/api/get-signed-url')
.then(res => res.json())
.then(({ url }) => {
fetch(url, {
method: 'PUT',
body: file.attachments[0]
}).then(() => done({ progress: 100, url }));
});
});
Why: This allows only authorized and time-limited access, reducing the risk of tampering or data misuse.
✅ Sandbox custom JS tools using customJS
Custom JS
Pass custom JS to the builder using the customJS
property during initialization.
unlayer.init({
customJS: '//cdn.muicss.com/mui-0.9.28/js/mui.min.js',
});
Or, pass the JS source code like this:
unlayer.init({
customJS: [
"
console.log('I am custom JS!');
"
]
});
Why: Custom tools loaded using customJS
run inside the builder’s own environment. This keeps them isolated and prevents the script from interfering with your application and accessing sensitive resources.
Note: For React applications, you have to bundle the custom tool code, host the file publicly, and then provide its URL in the customJS
parameter.
10. Advanced Workflows (Optional)
✅ Use headless mode for server-side rendering
// Example: Render stored design JSON using your own renderer
fetch('/api/get-design').then(res => res.json()).then((design) => {
unlayer.loadDesign(design);
});
Why: To store and manage your designs server-side and render only when needed—for full backend control.
✅ Auto-save user designs with onDesignChange
unlayer.init({
projectId: 1234,
onDesignChange: function (design) {
fetch('/api/autosave', {
method: 'POST',
body: JSON.stringify(design),
headers: { 'Content-Type': 'application/json' }
});
}
});
Why: To automatically save design changes as the user works, reducing data loss and improving UX during long editing sessions.
✅ Integrate with your templating engine for API-driven content delivery
unlayer.exportHtml((data) => {
const html = renderWithTemplateEngine(data.html);
sendEmail(html);
});
Why: Export content from Unlayer and inject it into your server-side logic for dynamic emails/landing pages, or automated campaigns.
So, What Really Matters?
Is it a builder that works or one that just belongs to your product?
The above-mentioned Unlayer best practices for developers go beyond integration. They are what help you deliver a builder that feels native and scalable.
Embed Unlayer!
Production-ready. Dev-friendly. Built for scale.