Building a Simple Blog Editor
In this tutorial, we'll create a complete blog post editor with image uploads and content persistence. This example integrates both Vercel Blob Storage and Neon PostgreSQL.
Project Setup
First, create a new React application:
npx create-react-app blog-editor
cd blog-editor
Install the necessary dependencies:
npm install lexical-editor-easy lexical @lexical/react @vercel/blob @neondatabase/serverless react react-dom
Environment Configuration
Create a .env
file in your project root:
REACT_APP_NEON_DATABASE_URL=your_neon_connection_string
REACT_APP_BLOB_READ_WRITE_TOKEN=your_vercel_blob_token
Setting Up API Routes
Create a file at src/api/upload-blob.js
to handle image uploads:
import { put } from '@vercel/blob';
import { v4 as uuidv4 } from 'uuid';
export async function handleBlobUpload(file) {
if (!file) {
throw new Error('No file provided');
}
// Generate a unique filename
const uniqueFilename = `${uuidv4()}-${file.name}`;
try {
const response = await put(uniqueFilename, file, {
access: 'public',
});
return response;
} catch (error) {
console.error('Error uploading to blob:', error);
throw error;
}
}
Creating the Blog Editor Component
Create a file at src/components/BlogEditor.js
:
import React, { useState } from 'react';
import {
LexicalEditor,
EditorToolbar,
BlobImageUploader,
NeonPersistencePlugin
} from 'lexical-editor-easy';
export default function BlogEditor({ postId }) {
const [title, setTitle] = useState('');
const [isSaved, setIsSaved] = useState(false);
const handleSave = (id) => {
setIsSaved(true);
setTimeout(() => setIsSaved(false), 2000);
};
return (
<div className="blog-editor">
<div className="editor-header">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Post title..."
className="title-input"
/>
</div>
<LexicalEditor placeholder="Write your blog post...">
<EditorToolbar>
<BlobImageUploader buttonText="Add Image" />
</EditorToolbar>
<NeonPersistencePlugin
connectionString={process.env.REACT_APP_NEON_DATABASE_URL}
contentId={postId || 'new-post'}
title={title}
onSave={handleSave}
saveDelay={2000}
/>
</LexicalEditor>
{isSaved && <div className="save-indicator">Content saved!</div>}
</div>
);
}
Styling the Blog Editor
Create a file at src/components/BlogEditor.css
:
.blog-editor {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.editor-header {
margin-bottom: 20px;
}
.title-input {
width: 100%;
padding: 10px;
font-size: 24px;
border: none;
border-bottom: 2px solid #e2e8f0;
outline: none;
}
.title-input:focus {
border-bottom-color: #4299e1;
}
.save-indicator {
position: fixed;
bottom: 20px;
right: 20px;
background: #48bb78;
color: white;
padding: 10px 20px;
border-radius: 4px;
animation: fadeIn 0.3s, fadeOut 0.3s 1.7s;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
Creating the Blog Post Manager
Create a file at src/components/BlogPostManager.js
:
import React, { useState, useEffect } from 'react';
import { initNeonDatabase } from 'lexical-editor-easy';
import BlogEditor from './BlogEditor';
import './BlogEditor.css';
export default function BlogPostManager() {
const [posts, setPosts] = useState([]);
const [selectedPostId, setSelectedPostId] = useState(null);
const [isLoading, setIsLoading] = useState(true);
// Initialize Neon Database
const neonDb = initNeonDatabase({
connectionString: process.env.REACT_APP_NEON_DATABASE_URL,
useWebsockets: true
});
// Load posts on mount
useEffect(() => {
async function loadPosts() {
try {
await neonDb.setupTables();
const loadedPosts = await neonDb.listContent(100);
setPosts(loadedPosts);
setIsLoading(false);
} catch (error) {
console.error('Error loading posts:', error);
setIsLoading(false);
}
}
loadPosts();
}, []);
const createNewPost = () => {
setSelectedPostId('new-post-' + Date.now());
};
const deletePost = async (id) => {
if (window.confirm('Are you sure you want to delete this post?')) {
try {
await neonDb.deleteContent(id);
setPosts(posts.filter(post => post.id !== id));
if (selectedPostId === id) {
setSelectedPostId(null);
}
} catch (error) {
console.error('Error deleting post:', error);
}
}
};
return (
<div className="blog-post-manager">
<div className="sidebar">
<h2>Your Blog Posts</h2>
<button className="new-post-button" onClick={createNewPost}>
Create New Post
</button>
{isLoading ? (
<p>Loading posts...</p>
) : (
<ul className="post-list">
{posts.length === 0 ? (
<li className="empty-state">No posts yet. Create one!</li>
) : (
posts.map(post => (
<li key={post.id} className="post-item">
<button
className={`post-link ${selectedPostId === post.id ? 'active' : ''}`}
onClick={() => setSelectedPostId(post.id)}
>
{post.title || 'Untitled Post'}
<span className="post-date">
{new Date(post.updated_at).toLocaleString()}
</span>
</button>
<button
className="delete-button"
onClick={() => deletePost(post.id)}
>
Delete
</button>
</li>
))
)}
</ul>
)}
</div>
<div className="content">
{selectedPostId ? (
<BlogEditor postId={selectedPostId} />
) : (
<div className="empty-state-container">
<h2>Select a post or create a new one</h2>
<button className="new-post-button" onClick={createNewPost}>
Create New Post
</button>
</div>
)}
</div>
</div>
);
}
Final App Component
Update your src/App.js
file:
import React from 'react';
import BlogPostManager from './components/BlogPostManager';
import './App.css';
function App() {
return (
<div className="App">
<header className="App-header">
<h1>Blog Post Editor</h1>
</header>
<main>
<BlogPostManager />
</main>
<footer>
<p>Powered by lexical-editor-easy</p>
</footer>
</div>
);
}
export default App;
Add Some Final Styling
Update or create src/App.css
:
.App {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.App-header {
background-color: #4299e1;
color: white;
padding: 1rem;
text-align: center;
}
main {
flex: 1;
display: flex;
}
footer {
background-color: #f8f9fa;
padding: 1rem;
text-align: center;
color: #718096;
}
.blog-post-manager {
display: flex;
width: 100%;
}
.sidebar {
width: 300px;
background-color: #f8f9fa;
padding: 20px;
border-right: 1px solid #e2e8f0;
}
.content {
flex: 1;
}
.post-list {
list-style: none;
padding: 0;
margin: 20px 0;
}
.post-item {
display: flex;
margin-bottom: 8px;
}
.post-link {
flex: 1;
text-align: left;
padding: 8px 12px;
background: none;
border: none;
cursor: pointer;
display: block;
border-radius: 4px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.post-link:hover {
background-color: #edf2f7;
}
.post-link.active {
background-color: #bee3f8;
}
.post-date {
display: block;
font-size: 12px;
color: #718096;
}
.delete-button {
background-color: #feb2b2;
color: #c53030;
border: none;
border-radius: 4px;
margin-left: 8px;
cursor: pointer;
}
.delete-button:hover {
background-color: #fc8181;
}
.new-post-button {
background-color: #4299e1;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
width: 100%;
}
.new-post-button:hover {
background-color: #3182ce;
}
.empty-state {
color: #718096;
font-style: italic;
padding: 10px 0;
}
.empty-state-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 80vh;
color: #718096;
}
.empty-state-container .new-post-button {
max-width: 200px;
margin-top: 20px;
}
Run the Application
Now you can start your application:
npm start
You should have a fully functional blog post editor with:
- Rich text editing capabilities
- Image uploads via Vercel Blob
- Automatic content persistence with Neon PostgreSQL
- Post management (create, list, select, delete)
Next Steps
To enhance your blog editor, consider adding:
- User authentication to protect posts
- Published vs. draft status
- Categories and tags
- SEO metadata fields
- Preview functionality
This tutorial demonstrates how lexical-editor-easy simplifies building rich content editing experiences with integrated storage solutions.