How To Add Markdown Support to Sanity Studio
Adding Markdown Support to Sanity Studio: Want a Better Content Editing Experience? This guide shows you how to add Markdown, render it in Next.js, and handle SEO.
This guide walks through the process of adding Markdown support to your Sanity Studio and rendering it properly in your Next.js frontend.
Table of Contents
- Introduction
- Installing Dependencies
- Configuring Sanity Studio
- Rendering Markdown in Next.js
- Handling Meta Fields and SEO
- Advanced Configuration
- Troubleshooting
Introduction
Sanity Studio comes with a powerful Portable Text editor by default, but sometimes you or your content creators might prefer working with Markdown. This guide shows you how to add Markdown support alongside Portable Text, giving your content team flexibility in how they create content.
Installing Dependencies
First, install the necessary packages:
# For Sanity Studio
npm install sanity-plugin-markdown easymde@2
# For Next.js frontend
npm install react-markdown remark-gfm rehype-raw rehype-sanitize rehype-highlight highlight.js
These packages provide:
sanity-plugin-markdown
: The Sanity plugin for Markdown editingeasymde
: The Markdown editor used by the pluginreact-markdown
: For rendering Markdown in Reactremark-gfm
: Adds GitHub Flavored Markdown supportrehype-raw
: Allows rendering of HTML within Markdownrehype-sanitize
: Sanitizes HTML to prevent XSS attacksrehype-highlight
: Adds syntax highlighting for code blockshighlight.js
: The syntax highlighter used by rehype-highlight
Configuring Sanity Studio
Adding the Markdown Schema
First, update your sanity.config.ts
file to include the Markdown schema:
import {markdownSchema} from 'sanity-plugin-markdown'
export default defineConfig({
// ... other config
plugins: [
markdownSchema(),
// ... other plugins
],
})
Creating a Custom Markdown Input Component
Create a custom component to enhance the Markdown editor experience:
// src/sanity/components/CustomMarkdownInput.tsx
'use client'
import { useMemo } from 'react'
import { MarkdownInput, MarkdownInputProps } from 'sanity-plugin-markdown'
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import hljs from 'highlight.js'
import 'highlight.js/styles/github-dark.css'
export function CustomMarkdownInput(props: any) {
const reactMdeProps: MarkdownInputProps['reactMdeProps'] = useMemo(() => {
return {
options: {
toolbar: [
'bold', 'italic', 'heading', 'strikethrough', '|',
'quote', 'unordered-list', 'ordered-list', '|',
'link', 'image', 'table', 'code', '|',
'preview', 'side-by-side', 'fullscreen', '|',
'guide'
],
autofocus: false,
spellChecker: true,
status: ['lines', 'words', 'cursor'],
previewRender: (markdownText) => {
// Use marked.parse synchronously with highlight.js for code blocks
const html = marked.parse(markdownText, {
async: false,
// @ts-ignore - Using any to bypass TypeScript errors
highlight: (code: string, language: string) => {
if (language && hljs.getLanguage(language)) {
return hljs.highlight(code, { language }).value;
}
return hljs.highlightAuto(code).value;
}
}) as string;
return DOMPurify.sanitize(html);
},
// Improved syntax highlighting
renderingConfig: {
singleLineBreaks: false,
codeSyntaxHighlighting: true,
},
// Better image upload handling
uploadImage: true,
imageUploadFunction: undefined, // Sanity handles this
// Better placeholder
placeholder: 'Write your content in Markdown...\n\nTip: For code blocks, use ```language\ncode here\n```',
// Better tab behavior
tabSize: 2,
// Better initial height
minHeight: '300px',
},
}
}, [])
return <MarkdownInput {...props} reactMdeProps={reactMdeProps} />
}
Updating Your Schema
Now, update your schema to include a Markdown field. For example, if you have a post schema:
// src/sanity/schemaTypes/postType.ts
import {DocumentTextIcon} from '@sanity/icons'
import {defineArrayMember, defineField, defineType} from 'sanity'
import {CustomMarkdownInput} from '../components/CustomMarkdownInput'
export const postType = defineType({
name: 'post',
title: 'Post',
type: 'document',
icon: DocumentTextIcon,
fields: [
// ... other fields
defineField({
name: 'body',
type: 'blockContent',
description: 'Rich text content using Portable Text (optional if using Markdown)',
validation: (Rule) =>
Rule.custom((body, context) => {
// If markdownContent exists, body is optional
if (context.document?.markdownContent && !body) {
return true
}
// If no markdownContent, body is required
if (!context.document?.markdownContent && !body) {
return 'Either Body or Markdown Content is required'
}
return true
}),
}),
defineField({
name: 'markdownContent',
title: 'Markdown Content',
type: 'markdown',
description: 'Alternative content format using Markdown with image upload support (optional if using Body)',
components: {
input: CustomMarkdownInput,
},
options: {
imageUrl: (imageAsset) => `${imageAsset.url}?w=800`,
},
validation: (Rule) =>
Rule.custom((markdownContent, context) => {
// If body exists, markdownContent is optional
if (context.document?.body && !markdownContent) {
return true
}
// If no body, markdownContent is required
if (!context.document?.body && !markdownContent) {
return 'Either Body or Markdown Content is required'
}
return true
}),
}),
// ... other fields
],
// ... preview config
})
This setup allows content creators to use either Portable Text or Markdown, making both fields optional but requiring at least one of them.
Setting Up CSS for the Studio
Create a layout file for your Sanity Studio to import the EasyMDE CSS:
// src/app/studio/[[...tool]]/layout.tsx
'use client'
import 'easymde/dist/easymde.min.css'
export default function StudioLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="sanity-studio">
{children}
</div>
)
}
Add some custom styles to your global CSS file:
/* src/app/globals.css */
/* Markdown Editor Styles */
.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;
}
.sanity-studio .EasyMDEContainer .editor-preview {
background-color: var(--card-bg-color, #f8fafc);
}
/* 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);
}
.dark .sanity-studio .EasyMDEContainer .editor-toolbar button {
color: var(--card-fg-color, #e2e8f0);
}
.dark .sanity-studio .EasyMDEContainer .editor-preview {
background-color: var(--card-bg-color, #1a202c);
color: var(--card-fg-color, #e2e8f0);
}
Rendering Markdown in Next.js
Setting Up ReactMarkdown
First, update your data fetching to include the markdownContent
field:
// src/sanity/lib/queries.ts
export const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0] {
_id,
title,
subtitle,
metaDescription,
slug,
mainImage,
body,
markdownContent,
publishedAt,
"author": author->{
name,
image,
bio,
slug,
role,
social {
twitter,
linkedin,
github
}
},
"categories": categories[]->title
}`)
Update your TypeScript interfaces to include the markdownContent
field:
// src/types/index.ts
export interface Post {
_id: string
title: string
slug: { current: string }
mainImage?: {
_type: 'image'
asset: {
_ref: string
_type: 'reference'
}
}
body?: any[] // Portable Text content (optional if markdownContent is used)
markdownContent?: string // Markdown content (optional if body is used)
excerpt?: string
publishedAt: string
author: Author
categories: string[]
}
Setting Up ReactMarkdown
Create a layout file for your blog post page to import the highlight.js CSS:
// src/app/blog/[slug]/layout.tsx
'use client'
import 'highlight.js/styles/github-dark.css'
export default function BlogPostLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div>
{children}
</div>
)
}
Now, update your blog post page component to render both Portable Text and Markdown content:
// src/app/blog/[slug]/page.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'
// ... other imports
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await sanityFetch<Post>({
query: POST_QUERY,
params: { slug },
tags: ['post']
})
// ... error handling and other code
return (
<>
{/* ... other JSX */}
{/* Content */}
<div className="prose prose-lg dark:prose-invert max-w-none">
{post.body && (
<PortableText
value={post.body}
components={portableTextComponents}
/>
)}
{post.body && post.markdownContent && (
<hr className="my-8 border-gray-200 dark:border-gray-800" />
)}
{post.markdownContent && (
<div className={post.body ? "mt-8 prose prose-lg dark:prose-invert max-w-none" : "prose prose-lg dark:prose-invert max-w-none"}>
{post.body && <h2 className="text-2xl font-bold mb-4">Additional Content</h2>}
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
components={{
h1: ({children, ...props}) => <h1 className="text-3xl font-bold mt-8 mb-4" {...props}>{children}</h1>,
h2: ({children, ...props}) => <h2 className="text-2xl font-bold mt-6 mb-3" {...props}>{children}</h2>,
h3: ({children, ...props}) => <h3 className="text-xl font-bold mt-5 mb-2" {...props}>{children}</h3>,
h4: ({children, ...props}) => <h4 className="text-lg font-bold mt-4 mb-2" {...props}>{children}</h4>,
h5: ({children, ...props}) => <h5 className="text-base font-bold mt-3 mb-1" {...props}>{children}</h5>,
h6: ({children, ...props}) => <h6 className="text-sm font-bold mt-3 mb-1" {...props}>{children}</h6>,
p: ({children, ...props}) => <p className="my-4" {...props}>{children}</p>,
a: ({children, ...props}) => <a className="text-blue-600 hover:underline" {...props}>{children}</a>,
ul: ({children, ...props}) => <ul className="list-disc pl-6 my-4" {...props}>{children}</ul>,
ol: ({children, ...props}) => <ol className="list-decimal pl-6 my-4" {...props}>{children}</ol>,
li: ({children, ...props}) => <li className="mb-1" {...props}>{children}</li>,
blockquote: ({children, ...props}) => <blockquote className="border-l-4 border-gray-300 dark:border-gray-700 pl-4 italic my-4" {...props}>{children}</blockquote>,
code: ({children, className, ...props}) => {
const match = /language-(\w+)/.exec(className || '');
return match ? (
<pre className="bg-gray-100 dark:bg-gray-800 rounded p-4 overflow-x-auto my-4">
<code className={className} {...props}>{children}</code>
</pre>
) : (
<code className="bg-gray-100 dark:bg-gray-800 rounded px-1 py-0.5" {...props}>{children}</code>
);
},
img: ({src, alt, ...props}) => <img className="max-w-full h-auto my-4 rounded" src={src} alt={alt || 'Image'} {...props} />,
table: ({children, ...props}) => <div className="overflow-x-auto my-4"><table className="min-w-full divide-y divide-gray-300 dark:divide-gray-700" {...props}>{children}</table></div>,
thead: ({children, ...props}) => <thead className="bg-gray-100 dark:bg-gray-800" {...props}>{children}</thead>,
tbody: ({children, ...props}) => <tbody className="divide-y divide-gray-200 dark:divide-gray-800" {...props}>{children}</tbody>,
tr: ({children, ...props}) => <tr {...props}>{children}</tr>,
th: ({children, ...props}) => <th className="px-4 py-2 text-left font-medium" {...props}>{children}</th>,
td: ({children, ...props}) => <td className="px-4 py-2" {...props}>{children}</td>,
hr: ({...props}) => <hr className="my-6 border-gray-300 dark:border-gray-700" {...props} />,
}}
>
{post.markdownContent}
</ReactMarkdown>
</div>
)}
</div>
{/* ... other JSX */}
</>
)
}
Custom Styling
Add these styles to your global CSS file for better code block rendering:
/* src/app/globals.css */
/* Code Block Styles */
.prose pre {
background-color: #1e1e1e;
border-radius: 0.375rem;
padding: 1rem;
overflow-x: auto;
margin: 1.5rem 0;
border: 1px solid #2d2d2d;
}
.prose code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.9em;
}
.prose pre code {
color: #e6e6e6;
background-color: transparent;
padding: 0;
border-radius: 0;
border: none;
}
.prose :not(pre) > code {
background-color: rgba(110, 118, 129, 0.1);
color: #24292e;
padding: 0.2em 0.4em;
border-radius: 0.25rem;
font-size: 0.85em;
white-space: nowrap;
}
.dark .prose :not(pre) > code {
background-color: rgba(110, 118, 129, 0.3);
color: #e6e6e6;
}
/* Syntax highlighting colors */
.hljs-keyword,
.hljs-selector-tag,
.hljs-type {
color: #ff79c6;
}
.hljs-literal,
.hljs-symbol,
.hljs-bullet,
.hljs-attribute {
color: #8be9fd;
}
.hljs-number,
.hljs-addition {
color: #bd93f9;
}
.hljs-string,
.hljs-regexp,
.hljs-deletion {
color: #f1fa8c;
}
.hljs-title,
.hljs-section,
.hljs-built_in,
.hljs-name {
color: #50fa7b;
}
.hljs-variable,
.hljs-template-variable,
.hljs-selector-id,
.hljs-selector-class {
color: #ffb86c;
}
.hljs-comment,
.hljs-quote,
.hljs-meta {
color: #6272a4;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
Handling Meta Fields and SEO
When using Markdown content, it's important to ensure that your meta fields and SEO information are correctly generated. This section covers how to extract meta information from Markdown content and set up Open Graph tags and JSON-LD structured data.
Extracting Meta Information from Markdown
For SEO purposes, you might want to extract the first paragraph or heading from your Markdown content to use as a description or excerpt. Here's a utility function to help with that:
// src/utils/markdown-utils.ts
import { remark } from 'remark';
import strip from 'strip-markdown';
import { unified } from 'unified';
export function extractExcerptFromMarkdown(markdown: string, maxLength = 160): string {
if (!markdown) return '';
// Process the markdown to extract plain text
const plainText = unified()
.use(remark)
.use(strip)
.processSync(markdown)
.toString();
// Get the first paragraph (or a portion of it)
const firstParagraph = plainText.split('\n\n')[0].trim();
// Truncate to maxLength if needed
if (firstParagraph.length <= maxLength) {
return firstParagraph;
}
// Find a good breaking point
const truncated = firstParagraph.substring(0, maxLength);
const lastSpace = truncated.lastIndexOf(' ');
return truncated.substring(0, lastSpace) + '...';
}
export function extractFirstHeadingFromMarkdown(markdown: string): string | null {
if (!markdown) return null;
// Look for the first heading (# Title)
const headingMatch = markdown.match(/^#\s+(.+)$/m);
return headingMatch ? headingMatch[1].trim() : null;
}
Install the required dependencies:
npm install remark strip-markdown unified
Setting Up Open Graph Tags
Update your blog post page to include Open Graph tags based on either the explicit meta fields or extracted from Markdown:
// src/app/blog/[slug]/page.tsx
import { Metadata } from 'next';
import { extractExcerptFromMarkdown } from '@/utils/markdown-utils';
// ... other imports
export async function generateMetadata({
params
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params;
const post = await sanityFetch<Post>({
query: POST_QUERY,
params: { slug },
tags: ['post']
});
if (!post) {
return {
title: 'Post Not Found',
description: 'The requested blog post could not be found.'
};
}
// Use explicit meta description or extract from content
const description = post.metaDescription ||
(post.markdownContent ? extractExcerptFromMarkdown(post.markdownContent) : undefined);
return {
title: post.title,
description,
openGraph: {
title: post.title,
description,
type: 'article',
publishedTime: post.publishedAt,
authors: [post.author.name],
images: post.mainImage?.asset ? [
{
url: urlForImage(post.mainImage)?.width(1200).height(630).url() || '',
width: 1200,
height: 630,
alt: post.title
}
] : undefined,
tags: post.categories
},
twitter: {
card: 'summary_large_image',
title: post.title,
description,
images: post.mainImage?.asset ?
[urlForImage(post.mainImage)?.width(1200).height(630).url() || ''] : undefined,
}
};
}
Generating JSON-LD
Add structured data with JSON-LD to improve SEO:
// src/utils/json-ld.ts
import { Post } from '@/types';
import { urlForImage } from '@/sanity/lib/image';
export function generateJsonLd(post: Post) {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://yourdomain.com';
return {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.metaDescription,
image: post.mainImage?.asset ?
urlForImage(post.mainImage)?.width(1200).height(630).url() : undefined,
datePublished: post.publishedAt,
dateModified: post.publishedAt,
author: {
'@type': 'Person',
name: post.author.name,
url: post.author.slug ? `${baseUrl}/authors/${post.author.slug.current}` : undefined
},
publisher: {
'@type': 'Organization',
name: 'Your Organization Name',
logo: {
'@type': 'ImageObject',
url: `${baseUrl}/logo.png`
}
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `${baseUrl}/blog/${post.slug.current}`
}
};
}
Then use it in your blog post page:
// In your BlogPostPage component
const jsonLd = generateJsonLd(post);
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{/* Rest of your component */}
</>
);
Updating the Schema for Better SEO
Enhance your post schema to include dedicated SEO fields:
// src/sanity/schemaTypes/postType.ts
// ... existing imports
export const postType = defineType({
// ... existing configuration
fields: [
// ... existing fields
defineField({
name: 'metaDescription',
title: 'Meta Description',
type: 'text',
description: 'This description appears in search engines and social media shares (max 155 characters)',
validation: (Rule) => Rule.max(155),
}),
defineField({
name: 'openGraphImage',
title: 'Open Graph Image',
type: 'image',
description: 'Image used for social media sharing. If not provided, the main image will be used.',
options: {
hotspot: true,
},
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative text',
description: 'Important for SEO and accessibility',
}
]
}),
// ... other fields
],
// ... rest of the configuration
});
With these additions, your blog posts will have proper meta tags and structured data regardless of whether you're using Portable Text or Markdown content.
Advanced Configuration
Making Content Fields Optional
The validation rules we added to the schema make the body
and markdownContent
fields optional, but require at least one of them to be present. This gives content creators flexibility to use either Portable Text or Markdown.
Enhancing the Editor Experience
You can further enhance the Markdown editor experience by:
- Adding more toolbar buttons: Customize the toolbar in the
CustomMarkdownInput
component. - Changing the theme: Import a different highlight.js theme for syntax highlighting.
- Customizing the preview: Modify the
previewRender
function to change how Markdown is rendered in the preview.
Troubleshooting
Common Issues
- CSS not loading: Make sure you've imported the CSS files in the correct layout files.
- TypeScript errors: Use
@ts-ignore
comments for any TypeScript errors related to third-party libraries. - Markdown not rendering: Check that you're including the
markdownContent
field in your GROQ queries. - Code blocks not highlighting: Ensure you've installed and configured rehype-highlight correctly.
Debugging Tips
- Check the browser console for any errors.
- Use the React DevTools to inspect the components and props.
- Test with simple Markdown content first, then add more complex elements.
Conclusion
You now have a fully functional Markdown editor in your Sanity Studio, with proper rendering in your Next.js frontend. This setup gives your content team the flexibility to use either Portable Text or Markdown, depending on their preferences and needs.
The implementation includes:
- A custom Markdown editor with syntax highlighting
- GitHub Flavored Markdown support
- Code block syntax highlighting
- Image uploads
- Dark mode support
- Responsive design
Happy content creating!