In 2023, we transitioned from our GitHub-based blog posting system to Sanity, a powerful content operating system. This move significantly accelerated our content creation process and improved our media asset management. However, our engineering team quickly discovered they missed the familiarity of writing in Markdown, as Sanity Studio ships with a Rich Text editor by default.
During our recent Milan hackathon/offsite, I tackled this challenge by implementing Markdown support in our Sanity dashboard. This post walks through how we enhanced our content creation workflow to accommodate both rich text and Markdown enthusiasts.
Why we chose Sanity?
Sanity describes itself as a "Content Operating System", which is essentially a fully customizable, code-first backend for content-driven websites and applications. We made the switch primarily for:
- Faster writing process - streamlined content creation workflows
- Better media asset management - no more checking image files into our GitHub repository.
- Flexible content modeling - adapting to our specific publishing needs.
Despite these advantages, our engineering team wasn't entirely comfortable with Sanity's default Rich Text editor. It turns out engineers really do love their Markdown!
Implementing Markdown Support
Modifying the Post Schema
The first step was updating our Post
schema to accommodate Markdown content alongside the existing Rich Text option:
// schemas/post.ts
import {defineField, defineType} from 'sanity';
export default defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
// ... other fields ...
defineField({
name: 'markdownBody',
title: 'Markdown Body',
description: 'Use this field for Markdown content',
type: 'markdown',
options: {
imageUrl: (imageAsset: any) => `${imageAsset.url}?w=800`,
},
validation: rule =>
rule.custom((currentValue: any, context: any) => {
const richTextBody = context.document?.body;
if (!currentValue && !richTextBody) {
return 'Either Rich Text Body or Markdown Body must be provided';
}
return true;
}),
}),
// ... other fields ...
],
});
This modification above does the following:
- Adds a dedicated
markdownBody
field. - Implements validation ensuring at least one content format is used.
- Gives writers the freedom to use their preferred writing method.
Creating a Custom Markdown Editor
Next, we built a custom Markdown input component that delivers the familiar editing experience our team was looking for:
// components/CustomMarkdownInput/index.tsx
import React, {useMemo} from 'react';
import {MarkdownInput} from 'sanity-plugin-markdown';
import DOMPurify from 'dompurify';
import {marked} from 'marked';
import hljs from 'highlight.js';
import 'highlight.js/styles/github-dark.css';
import 'easymde/dist/easymde.min.css';
export function CustomMarkdownInput(props) {
const reactMdeProps = useMemo(() => {
return {
options: {
toolbar: [
'heading',
'bold',
'italic',
'|',
'code',
'strikethrough',
'quote',
'|',
'unordered-list',
'ordered-list',
'|',
'link',
'image',
'|',
'preview',
'side-by-side',
'|',
'guide',
],
autofocus: false,
spellChecker: true,
status: ['lines', 'words', 'cursor'],
previewRender: markdownText => {
const html = marked.parse(markdownText, {
async: false,
highlight: (code, language) => {
if (language && hljs.getLanguage(language)) {
return hljs.highlight(code, {language}).value;
}
return hljs.highlightAuto(code).value;
},
});
return DOMPurify.sanitize(html);
},
renderingConfig: {
singleLineBreaks: false,
codeSyntaxHighlighting: true,
},
uploadImage: true,
placeholder: 'Write your content in Markdown...\n\nTip: For code blocks, use ```language\ncode here\n```',
tabSize: 2,
minHeight: '300px',
},
};
}, []);
return <MarkdownInput {...props} reactMdeProps={reactMdeProps} />;
}
Our custom editor integrates:
- The
sanity-plugin-markdown
base editor marked
for Markdown parsinghighlight.js
for code syntax highlightingDOMPurify
for HTML sanitization
Key features include:
- A comprehensive toolbar with common formatting options
- Real-time preview and side-by-side editing
- Code syntax highlighting with language detection
- Spell checking
- Status bar showing lines, words, and cursor position
- Image upload support
Styling the Editor
We also created a custom CSS file to ensure the editor fits our design system:
/* styles/global.css */
.sanity-studio .EasyMDEContainer {
font-size: 16px;
}
.sanity-studio .EasyMDEContainer .CodeMirror {
border-radius: 4px;
border-color: var(--card-border-color, #e2e8f0);
}
.sanity-studio .EasyMDEContainer .editor-toolbar {
border-color: var(--card-border-color, #e2e8f0);
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
/* Dark mode support */
.dark .sanity-studio .EasyMDEContainer .CodeMirror {
border-color: var(--card-border-color, #2d3748);
background-color: var(--card-bg-color, #1a202c);
color: var(--card-fg-color, #e2e8f0);
}
.dark .sanity-studio .EasyMDEContainer .editor-toolbar {
border-color: var(--card-border-color, #2d3748);
background-color: var(--card-bg-color, #1a202c);
}
Configuring Sanity
With our components ready, we updated the Sanity configuration to include the Markdown plugin:
// sanity.config.ts
import {defineConfig} from 'sanity';
import {markdownSchema} from 'sanity-plugin-markdown';
import {CustomMarkdownInput} from './components/CustomMarkdownInput';
export default defineConfig({
// ... other config
plugins: [
// ... other plugins
markdownSchema({input: CustomMarkdownInput}),
],
});
Rendering Markdown Content
To display the Markdown content on our blog, we created a specialized renderer:
// components/SanityPostRenderer/MarkdownRenderer.tsx
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import rehypeHighlight from 'rehype-highlight';
const sanitizeConfig = {
tagNames: [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'a', 'ul', 'ol', 'li', 'blockquote',
'code', 'pre', 'img', 'table', 'thead',
'tbody', 'tr', 'th', 'td', 'hr', 'iframe',
'span', 'strong', 'em', 'del',
],
attributes: {
a: ['href', 'title', 'target', 'rel'],
img: ['src', 'alt', 'title'],
iframe: ['src', 'width', 'height', 'frameborder', 'allow', 'allowfullscreen', 'title'],
code: ['class', 'className', 'node'],
pre: ['class', 'className'],
// ... other attributes
},
protocols: {
src: ['http', 'https', 'data'],
href: ['http', 'https', 'mailto', 'tel'],
},
};
const MarkdownRenderer = ({post}) => {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeConfig], rehypeHighlight]}
components={{
h1: ({children}) => <h1 className="text-3xl font-bold mt-8 mb-4">{children}</h1>,
h2: ({children}) => <h2 className="text-2xl font-bold mt-6 mb-3">{children}</h2>,
// ... other component customizations
iframe: ({node, ...props}) => (
<IframePreview
node={{
url: props.src,
}}
{...props}
/>
),
}}
>
{post}
</ReactMarkdown>
);
};
This renderer includes:
react-markdown
as the base renderer- GitHub Flavored Markdown support via
remark-gfm
- Raw HTML support through
rehype-raw
- HTML sanitization with
rehype-sanitize
- Code syntax highlighting via
rehype-highlight
Updating Sanity Queries
We also updated our Sanity queries to include the new Markdown field:
// utils/sanity.queries.ts
export const singlePostQuery = groq`
*[_type == "post" && slug.current == $slugWithoutDates][0] {
title,
"authorLinks": author[]->slug,
"authors": author[]->name,
mainImage,
description,
publishedAt,
"tags": tags[]->title,
slug,
body,
markdownBody
}
`;
With all the changes above, we were now able to write blog posts in Markdown and have them render nicely on the Polar Signals blog.
This article was in fact written with the newly added Markdown editor!
One more thing
As a bonus enhancement to our workflow, we've integrated Vercel's Comments Toolbar, enabling real-time feedback on blog post drafts. Team members who are signed into our Vercel organization can easily add comments to drafts, streamlining our review process.
This was quite easy to do. We used the @vercel/toolbar package to enable the toolbar on the blog post pages.
// next.config.js
const withVercelToolbar = require('@vercel/toolbar/plugins/next')();
module.exports = withBundleAnalyzer(
withMDX(
withVercelToolbar({
// ... other next config here
})
)
)
// src/components/BlogPostPreview/index.tsx
import {VercelToolbar} from '@vercel/toolbar/next';
export default function BlogPostPreview() {
return (
<>
<BlogPost meta={meta} post={post} />
<VercelToolbar />
</>
);
}
You can read more about how to implement this here.
Conclusion
By adding Markdown support to our Sanity CMS, we've created a more flexible content creation environment that caters to our team's preferences. Engineers can now work in the familiar Markdown syntax they love, while others can continue using the rich text editor.
The implementation was straightforward and has significantly improved our content creation experience. I hope this guide helps you in implementing a Markdown editor in your writing workflow.