Integrating with a CMS
In this tutorial, we'll show you how to integrate the Lexical Editor with a headless CMS, using a custom API for content persistence.
Introduction
Modern content management often involves decoupling the CMS backend from the frontend display. Lexical Editor Easy can be integrated with any headless CMS that provides a content API. We'll demonstrate this using a generic REST API approach that you can adapt to your specific CMS.
Prerequisites
- A React application set up
- Access to a headless CMS or API endpoint
- Basic understanding of async operations in JavaScript
Installation
First, install Lexical Editor Easy and its dependencies:
npm install lexical-editor-easy lexical @lexical/react react react-dom axios
Setting up the CMS Connector
Create a service to connect to your CMS:
// src/services/cmsService.js
import axios from 'axios';
const API_URL = 'https://your-cms-api-endpoint.com/api';
const API_KEY = 'your-cms-api-key'; // Store this securely
export const cmsService = {
// Fetch a single content item by ID
async getContent(contentId) {
try {
const response = await axios.get(`${API_URL}/content/${contentId}`, {
headers: {
'Authorization': `Bearer ${API_KEY}`
}
});
return response.data;
} catch (error) {
console.error('Error fetching content:', error);
throw error;
}
},
// Save or update content
async saveContent(contentId, data) {
try {
const response = await axios({
method: contentId ? 'PUT' : 'POST',
url: contentId ? `${API_URL}/content/${contentId}` : `${API_URL}/content`,
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json'
},
data: {
content: data.content,
title: data.title,
status: data.status || 'draft',
// Add other metadata as required by your CMS
}
});
return response.data;
} catch (error) {
console.error('Error saving content:', error);
throw error;
}
},
// List content items
async listContent(page = 1, limit = 20) {
try {
const response = await axios.get(`${API_URL}/content`, {
headers: {
'Authorization': `Bearer ${API_KEY}`
},
params: {
page,
limit
}
});
return response.data;
} catch (error) {
console.error('Error listing content:', error);
throw error;
}
}
};
Creating the CMS Editor Component
Now create a component that uses the Lexical Editor with your CMS service:
// src/components/CMSEditor.jsx
import React, { useState, useEffect } from 'react';
import { LexicalEditor, EditorToolbar, BlobImageUploader } from 'lexical-editor-easy';
import { cmsService } from '../services/cmsService';
import './CMSEditor.css';
export default function CMSEditor({ contentId }) {
const [initialContent, setInitialContent] = useState(null);
const [title, setTitle] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState('');
const [status, setStatus] = useState('draft');
// Load content from CMS when component mounts or contentId changes
useEffect(() => {
if (contentId) {
const loadContent = async () => {
try {
const content = await cmsService.getContent(contentId);
setInitialContent(content.editorState);
setTitle(content.title || '');
setStatus(content.status || 'draft');
} catch (error) {
console.error('Failed to load content:', error);
setSaveStatus('Error loading content');
}
};
loadContent();
}
}, [contentId]);
// Handle editor content change and save
const handleContentChange = async (editorState) => {
// Only save if content has been loaded
if (initialContent === null) return;
setIsSaving(true);
setSaveStatus('Saving...');
try {
const result = await cmsService.saveContent(contentId, {
content: JSON.stringify(editorState),
title,
status
});
setSaveStatus('Saved successfully');
// If this is a new content, we might want to update the URL with new ID
if (!contentId && result.id) {
window.history.pushState({}, '', `/editor/${result.id}`);
}
} catch (error) {
setSaveStatus('Error saving content');
} finally {
setIsSaving(false);
// Clear save status after a delay
setTimeout(() => setSaveStatus(''), 3000);
}
};
// Handle content publish
const handlePublish = async () => {
setIsSaving(true);
setSaveStatus('Publishing...');
try {
await cmsService.saveContent(contentId, {
content: initialContent,
title,
status: 'published'
});
setStatus('published');
setSaveStatus('Published successfully');
} catch (error) {
setSaveStatus('Error publishing content');
} finally {
setIsSaving(false);
setTimeout(() => setSaveStatus(''), 3000);
}
};
return (
<div className="cms-editor">
<div className="cms-editor-header">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter title..."
className="cms-editor-title"
/>
<div className="cms-editor-actions">
<span className="cms-editor-status">
Status: {status === 'draft' ? '🔵 Draft' : '🟢 Published'}
</span>
<button
onClick={handlePublish}
disabled={isSaving || status === 'published'}
className="publish-button"
>
{status === 'published' ? 'Published' : 'Publish'}
</button>
{saveStatus && (
<span className="save-status">{saveStatus}</span>
)}
</div>
</div>
<LexicalEditor
initialState={initialContent}
onChange={handleContentChange}
placeholder="Start writing your content..."
>
<EditorToolbar>
<BlobImageUploader buttonText="Insert Image" />
</EditorToolbar>
</LexicalEditor>
</div>
);
}
Adding Styles for the CMS Editor
Create a CSS file for styling:
/* src/components/CMSEditor.css */
.cms-editor {
display: flex;
flex-direction: column;
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
.cms-editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
}
.cms-editor-title {
font-size: 24px;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 4px;
flex: 1;
min-width: 200px;
margin-right: 15px;
}
.cms-editor-actions {
display: flex;
align-items: center;
gap: 15px;
}
.cms-editor-status {
font-size: 14px;
color: #4a5568;
}
.publish-button {
background-color: #4299e1;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
}
.publish-button:hover {
background-color: #3182ce;
}
.publish-button:disabled {
background-color: #cbd5e0;
cursor: not-allowed;
}
.save-status {
font-size: 14px;
color: #4a5568;
animation: fadeOut 3s forwards;
}
@keyframes fadeOut {
0% { opacity: 1; }
70% { opacity: 1; }
100% { opacity: 0; }
}
Content List Component
Let's create a component to list and manage content from the CMS:
// src/components/ContentList.jsx
import React, { useState, useEffect } from 'react';
import { cmsService } from '../services/cmsService';
import './ContentList.css';
export default function ContentList({ onSelectContent }) {
const [contents, setContents] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const loadContents = async (pageNum = 1) => {
setIsLoading(true);
try {
const response = await cmsService.listContent(pageNum);
if (pageNum === 1) {
setContents(response.items);
} else {
setContents([...contents, ...response.items]);
}
setHasMore(response.hasMore);
setPage(pageNum);
} catch (error) {
console.error('Failed to load content list:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadContents();
}, []);
const createNewContent = () => {
onSelectContent(null); // null indicates new content
};
const loadMore = () => {
if (!isLoading && hasMore) {
loadContents(page + 1);
}
};
return (
<div className="content-list">
<div className="content-list-header">
<h2>Content Items</h2>
<button onClick={createNewContent} className="new-content-button">
Create New
</button>
</div>
{isLoading && page === 1 ? (
<div className="loading-indicator">Loading content...</div>
) : (
<>
{contents.length === 0 ? (
<div className="empty-state">
No content found. Create your first content item!
</div>
) : (
<ul className="content-items">
{contents.map(item => (
<li key={item.id} className="content-item">
<div className="content-item-details" onClick={() => onSelectContent(item.id)}>
<h3 className="content-title">{item.title || 'Untitled'}</h3>
<div className="content-meta">
<span className={`content-status status-${item.status}`}>
{item.status}
</span>
<span className="content-date">
{new Date(item.updatedAt).toLocaleString()}
</span>
</div>
</div>
</li>
))}
</ul>
)}
{hasMore && (
<button
onClick={loadMore}
disabled={isLoading}
className="load-more-button"
>
{isLoading ? 'Loading...' : 'Load More'}
</button>
)}
</>
)}
</div>
);
}
Add the corresponding styles:
/* src/components/ContentList.css */
.content-list {
padding: 20px;
background-color: #f7fafc;
border-radius: 8px;
margin-bottom: 30px;
}
.content-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.new-content-button {
background-color: #4299e1;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
}
.new-content-button:hover {
background-color: #3182ce;
}
.content-items {
list-style: none;
padding: 0;
margin: 0;
}
.content-item {
background-color: white;
border-radius: 4px;
margin-bottom: 10px;
border: 1px solid #e2e8f0;
transition: all 0.2s;
}
.content-item:hover {
border-color: #4299e1;
transform: translateY(-2px);
}
.content-item-details {
padding: 15px;
cursor: pointer;
}
.content-title {
margin: 0 0 5px 0;
font-size: 18px;
}
.content-meta {
display: flex;
justify-content: space-between;
font-size: 14px;
color: #718096;
}
.content-status {
font-weight: 500;
}
.status-draft {
color: #3182ce;
}
.status-published {
color: #38a169;
}
.load-more-button {
width: 100%;
background-color: #edf2f7;
border: 1px solid #e2e8f0;
padding: 10px;
border-radius: 4px;
margin-top: 15px;
cursor: pointer;
}
.load-more-button:hover {
background-color: #e2e8f0;
}
.load-more-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading-indicator, .empty-state {
padding: 20px;
text-align: center;
color: #718096;
}
Main App Component
Let's put it all together in an App component:
// src/App.jsx
import React, { useState } from 'react';
import ContentList from './components/ContentList';
import CMSEditor from './components/CMSEditor';
import './App.css';
function App() {
const [selectedContentId, setSelectedContentId] = useState(null);
const [isEditing, setIsEditing] = useState(false);
const handleSelectContent = (contentId) => {
setSelectedContentId(contentId);
setIsEditing(true);
};
const handleBackToList = () => {
setIsEditing(false);
};
return (
<div className="app">
<header className="app-header">
<h1>CMS Editor Demo</h1>
</header>
<main className="app-main">
{isEditing ? (
<div>
<button onClick={handleBackToList} className="back-button">
← Back to List
</button>
<CMSEditor contentId={selectedContentId} />
</div>
) : (
<ContentList onSelectContent={handleSelectContent} />
)}
</main>
<footer className="app-footer">
<p>Powered by lexical-editor-easy</p>
</footer>
</div>
);
}
export default App;
Final styles for the App:
/* src/App.css */
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-header {
background-color: #2d3748;
color: white;
padding: 1rem;
text-align: center;
}
.app-main {
flex: 1;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
width: 100%;
}
.app-footer {
background-color: #2d3748;
color: white;
padding: 1rem;
text-align: center;
margin-top: auto;
}
.back-button {
background: none;
border: none;
color: #4299e1;
font-size: 16px;
cursor: pointer;
margin-bottom: 20px;
padding: 0;
}
.back-button:hover {
text-decoration: underline;
}
Preview Component
Let's add a preview component to see how the content would look when published:
// src/components/ContentPreview.jsx
import React, { useState, useEffect } from 'react';
import { cmsService } from '../services/cmsService';
import './ContentPreview.css';
export default function ContentPreview({ contentId, onClose }) {
const [content, setContent] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadContent = async () => {
setIsLoading(true);
try {
const contentData = await cmsService.getContent(contentId);
setContent(contentData);
} catch (error) {
console.error('Failed to load content for preview:', error);
} finally {
setIsLoading(false);
}
};
if (contentId) {
loadContent();
}
}, [contentId]);
if (isLoading) {
return (
<div className="content-preview loading">
<div className="preview-header">
<h2>Preview</h2>
<button onClick={onClose} className="close-button">✕</button>
</div>
<div className="preview-loading">Loading preview...</div>
</div>
);
}
if (!content) {
return (
<div className="content-preview error">
<div className="preview-header">
<h2>Preview</h2>
<button onClick={onClose} className="close-button">✕</button>
</div>
<div className="preview-error">Failed to load content</div>
</div>
);
}
// Note: In a real implementation, you would use a proper HTML renderer
// for the Lexical JSON content. This is just a placeholder.
return (
<div className="content-preview">
<div className="preview-header">
<h2>Preview</h2>
<button onClick={onClose} className="close-button">✕</button>
</div>
<div className="preview-content">
<h1 className="preview-title">{content.title}</h1>
<div className="preview-body">
{/*
In reality, you would render the actual content from Lexical state here.
This could involve using a LexicalRichTextPlugin in read-only mode
or converting the Lexical state to HTML.
*/}
<div className="preview-placeholder">
[Content would be rendered here]
</div>
</div>
</div>
</div>
);
}
Add styles for the preview:
/* src/components/ContentPreview.css */
.content-preview {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 60%;
max-width: 800px;
background-color: white;
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1);
z-index: 1000;
display: flex;
flex-direction: column;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
border-bottom: 1px solid #e2e8f0;
}
.preview-header h2 {
margin: 0;
}
.close-button {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #718096;
}
.preview-content {
flex: 1;
padding: 20px;
overflow-y: auto;
}
.preview-title {
margin-top: 0;
margin-bottom: 20px;
font-size: 28px;
}
.preview-loading, .preview-error {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #718096;
}
.preview-error {
color: #e53e3e;
}
.preview-placeholder {
padding: 40px;
border: 2px dashed #e2e8f0;
border-radius: 8px;
text-align: center;
color: #a0aec0;
}
Conclusion
You've now created a comprehensive CMS integration with Lexical Editor Easy! This implementation includes:
- A CMS service for connecting to your API
- Content creation and editing with the Lexical Editor
- Content listing and management
- Publishing workflow
- Content preview
You can extend this foundation to include additional features like:
- User permissions and roles
- Content versioning
- Collaborative editing
- Media library integration
- SEO metadata
- Custom content types
Remember to adapt the API connection code to your specific CMS's requirements and authentication methods.