A blog writing workspace with a breadcrumb bar, a title header with draft status and last-saved time, and a docked settings panel. The editor card holds a cover image with alt text, a large title and subtitle, and a body editor with headings, lists, quotes, callouts, syntax-highlighted code blocks, images, tables and YouTube embeds. Markdown pasted from anywhere turns into formatted content, and pastes from Google Docs, Word or Notion come in clean. Switch to Preview to see the post as readers will, with the byline and reading time. The settings panel holds the URL slug, tag chips, SEO title and meta description, with character limits and a live search-result preview. Drafts autosave through onSave, and Publish hands the finished post to onPublish.
Installation
$ pnpm dlx shadcn@latest add @editorcn-pro/blog-post-editor
The CLI also installs the Markdown, Image Placeholder and Table extensions and the Static Renderer.
Usage
import { BlogPostEditor } from "@/components/templates/blog-post-editor";
export const PostEditor = ({ post, user }) => (
<BlogPostEditor
author={{ avatar: user.avatar, name: user.name }}
initialContent={post.content}
initialPost={post}
onSave={(draft) => saveDraft(post.id, draft)}
onPublish={(published) => publishPost(post.id, published)}
onUploadImage={(file) => uploadToStorage(file)}
siteUrl="acme.dev/blog"
/>
);onSave and onPublish both get a BlogPost:
interface BlogPost {
title: string;
subtitle: string;
slug: string;
tags: string[];
cover: { src: string; alt: string } | null;
metaTitle: string;
description: string;
status: "draft" | "published";
content: JSONContent;
markdown: string;
}content is Tiptap JSON, ready for the Static Renderer. markdown is the full post as a Markdown file with front matter, so you can commit it straight to an MDX or Markdown blog. The slug falls back to the title when left empty.
onSave runs 800ms after the last change. Return a promise to keep the status on "Saving…" until it resolves; a rejected promise shows "Couldn't save". onPublish works the same way, and the post switches to Published once it resolves.
Markdown
initialContent accepts a Markdown string or Tiptap JSON. ⋯ → Import Markdown reads a file's front matter (title, subtitle, slug, tags, cover, metaTitle, description) into the post fields. Without front matter, a leading # Heading becomes the title. Export Markdown and Copy as Markdown write the same format back:
---
title: "How we made checkout 3x faster"
slug: "how-we-made-checkout-3x-faster"
tags: ["performance", "frontend"]
---
Last spring our checkout page took **4.8 seconds**...Covers stored as data URLs are left out of the front matter. Callouts round-trip as GitHub alerts (> [!NOTE], > [!TIP], > [!WARNING]).
Images
Without onUploadImage, covers are stored as data URLs. That works for demos, but large posts get heavy, so pass an upload function that returns a hosted URL for production.
Props
| Prop | Type | Description |
|---|---|---|
initialContent | string | JSONContent | Markdown string or Tiptap JSON for the body |
initialPost | Partial<BlogPost> | Title, subtitle, slug, tags, cover, SEO, status |
author | { name, avatar? } | Byline shown in the header and preview |
breadcrumbs | string[] | Path shown above the title, default Blog, Posts |
onSave | function | Gets the BlogPost after edits settle |
onPublish | function | Gets the BlogPost with status: "published" |
onUploadImage | (file) => Promise<string> | Uploads a cover and returns its URL |
siteUrl | string | Shown in the slug hint and search preview |
className | string | Outer container classes |