- How I Added Image Gallery Support to Sanity CMS with Markdown Editor Integration
How I Added Image Gallery Support to Sanity CMS with Markdown Editor Integration
Upgrading Markdown in Sanity CMS with Modern Image Gallery Support

If you've been following my blog, you know I use Sanity as the CMS for buildwithmatija.com. Like many devs, I love tinkering and sharing what I learn along the way. Lately, I found myself working on a new section and realized that using the regular rich text editor in Sanity was starting to get in the way of my workflow. I wanted something cleaner and more efficient, so I made the switch to Markdown for my content editing.
Markdown just makes sense. It's easy to pick up, keeps things focused, and lets me write without falling into the trap of endless formatting options. The switch, however, quickly revealed a big pain point: images. Uploading images, grabbing links, and dropping them into the Markdown by hand really broke my flow. It felt outdated, especially when modern CMS platforms set the bar a lot higher.
I knew I needed a better experience, both for myself and anyone using this setup. That's why I decided to add proper image gallery support to my Markdown workflow in Sanity. In this guide, I'll walk you through exactly how I built it. We'll create a gallery field in Sanity, display optimized Markdown-ready image URLs, integrate gallery picking into the Markdown editor, and tie it all together with image optimization. If you haven't set up Markdown support in Sanity yet, I recommend reading my earlier guide on adding Markdown to Sanity Studio first.
Tech stack:
- Next.js 15+ with TypeScript
- Sanity CMS v3
- React Markdown Editor (sanity-plugin-markdown)
- Sanity Image URL Builder
Prerequisites
Before diving in, make sure you have:
- ✅ A working Sanity Studio setup with Next.js
- ✅ Markdown editor already integrated (see my earlier guide)
- ✅ Basic understanding of Sanity schemas and custom components
- ✅ Font Awesome icons available (for the gallery button)
Your project should already have this basic structure:
src/
├── sanity/
│ ├── env.ts # Environment configuration
│ ├── schemaTypes/
│ │ └── postType.ts # Your existing post schema
│ └── components/
│ └── CustomMarkdownInput.tsx # Your existing markdown editor
└── sanity.config.ts # Sanity configuration
If you're missing any of these pieces, check out my previous article on setting up Markdown in Sanity first.
The Problem I Was Trying to Solve
The Pain Point
Working with markdown content in Sanity, I kept running into this annoying workflow whenever I wanted to add images:
- Upload images separately to Sanity
- Hunt down the asset URLs manually
- Construct markdown image syntax by hand
- Deal with unoptimized, ugly URLs
What I really wanted was simple:
"I need an image gallery that lets me upload images to Sanity and easily grab URLs for my markdown content."
This frustrated me because while Sanity is amazing at asset management, there was this awkward disconnect between the CMS interface and my markdown writing flow.
Why I Had to Fix This
I needed something that would bridge the gap between Sanity's powerful asset management and the simplicity of markdown writing. This was especially important for me because I write:
- Blog posts with lots of images
- Technical guides with screenshots
- Content where I'm constantly referencing visuals
How I Approached the Solution
I considered a few different ways to tackle this:
Option 1: Direct Asset References
I could use Sanity's built-in image references directly in markdown.
- Pros: Works with Sanity's native features
- Cons: Super complex to render, and the markdown wouldn't be portable
Option 2: Custom Markdown Syntax
I thought about creating something like [[gallery:image-id]]
syntax.
- Pros: Would keep the markdown clean
- Cons: I'd have to build custom parsing logic
Option 3: Gallery + URL Helper (What I Built)
Add a gallery field with a helper that spits out markdown-ready URLs.
- Pros: Standard markdown, one-click copying, nice visual interface
- Cons: More components to build
I went with Option 3 because it gave me the best experience as a content creator while keeping everything in standard markdown format.
How I Built It
Step 1: Adding the Gallery Field to My Post Schema
The first thing I needed to do was add a gallery field to my existing post schema. I wanted this to store an array of images along with their metadata like alt text and captions.
Where I put this: In my existing post schema file at src/sanity/schemaTypes/postType.ts
// Add this import at the top
import { GalleryInput } from '../components/GalleryInput'
// Add this field after your mainImage field
defineField({
name: 'gallery',
title: 'Image Gallery',
type: 'array',
description: 'Upload images here to get URLs for your markdown content',
of: [
{
type: 'image',
options: {
hotspot: true,
},
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative text',
description: 'Alt text for accessibility and SEO',
validation: (Rule) => Rule.required(),
},
{
name: 'caption',
type: 'string',
title: 'Caption',
description: 'Optional caption for the image',
}
]
}
],
options: {
layout: 'grid',
},
components: {
input: GalleryInput,
},
}),
What I was trying to accomplish here: I wanted to create a field that could hold multiple images, each with proper metadata. The key thing was making sure each image would have alt text (for accessibility) and optional captions for context.
The important parts explained:
defineField()
is how you define fields in Sanity v3of: [{ type: 'image' }]
tells Sanity this array contains image objectshotspot: true
enables smart cropping - super useful for responsive imagesvalidation: (Rule) => Rule.required()
forces people to add alt text (which I care about for SEO)components: { input: GalleryInput }
swaps out Sanity's default array interface for my custom one
The GalleryInput
component is what I'll build next to make this gallery actually useful.
Step 2: Building the URL Helper Component
This was the heart of what I needed - a component that would take my uploaded images and spit out optimized URLs that I could easily copy into my markdown.
Where I created this: src/sanity/components/GalleryUrlHelper.tsx
'use client'
import React, { useState } from 'react'
import { Button, Card, Flex, Stack, Text, Box } from '@sanity/ui'
import { CopyIcon, CheckmarkIcon, ChevronDownIcon, ChevronUpIcon } from '@sanity/icons'
import imageUrlBuilder from '@sanity/image-url'
import { createClient } from 'next-sanity'
import { apiVersion, dataset, projectId } from '@/sanity/env'
interface GalleryImage {
_type: 'image'
asset: any
alt?: string
caption?: string
}
interface GalleryUrlHelperProps {
images: GalleryImage[]
}
export function GalleryUrlHelper({ images }: GalleryUrlHelperProps) {
const [isExpanded, setIsExpanded] = useState(false)
const [copiedIndex, setCopiedIndex] = useState<number | null>(null)
// Create a client without server-side token for browser use
const browserClient = createClient({
projectId,
dataset,
apiVersion,
useCdn: true,
})
const builder = imageUrlBuilder(browserClient)
if (!images || images.length === 0) {
return (
<Card padding={3} tone="transparent" border>
<Text size={1} muted>
No gallery images uploaded yet. Upload images above to get URLs for your markdown content.
</Text>
</Card>
)
}
const handleCopy = async (markdown: string, index: number) => {
try {
await navigator.clipboard.writeText(markdown)
setCopiedIndex(index)
setTimeout(() => setCopiedIndex(null), 2000)
} catch (err) {
console.error('Failed to copy:', err)
}
}
return (
<Card padding={3} tone="transparent" border>
<Stack space={3}>
<Flex align="center" justify="space-between">
<Text weight="semibold">
Image URLs for Markdown ({images.length} {images.length === 1 ? 'image' : 'images'})
</Text>
<Button
icon={isExpanded ? ChevronUpIcon : ChevronDownIcon}
mode="ghost"
onClick={() => setIsExpanded(!isExpanded)}
text={isExpanded ? 'Hide' : 'Show'}
/>
</Flex>
{isExpanded && (
<Stack space={3}>
{images.map((image, index) => {
if (!image.asset) return null
let optimizedUrl, thumbnailUrl
try {
// Handle both resolved and unresolved asset references
const assetRef = image.asset._ref || image.asset._id || image.asset
optimizedUrl = builder.image(assetRef).width(800).quality(80).url()
thumbnailUrl = builder.image(assetRef).width(150).height(150).fit('crop').url()
} catch (error) {
console.error('Error building image URL:', error)
optimizedUrl = image.asset.url || 'undefined'
thumbnailUrl = image.asset.url || 'undefined'
}
const altText = image.alt || `Image ${index + 1}`
const markdown = ``
return (
<Card key={index} padding={3} tone="default" border>
<Flex gap={3} align="flex-start">
<Box flex="none">
<img
src={thumbnailUrl}
alt={altText}
style={{
width: '60px',
height: '60px',
objectFit: 'cover',
borderRadius: '4px'
}}
/>
</Box>
<Stack space={2} flex={1}>
<Text size={1} weight="medium">
{altText}
</Text>
{image.caption && (
<Text size={1} muted>
{image.caption}
</Text>
)}
<Card padding={2} tone="transparent" border>
<Text size={1} style={{ fontFamily: 'monospace' }}>
{markdown}
</Text>
</Card>
<Button
icon={copiedIndex === index ? CheckmarkIcon : CopyIcon}
text={copiedIndex === index ? 'Copied!' : 'Copy Markdown'}
tone={copiedIndex === index ? 'positive' : 'default'}
mode="ghost"
size={1}
onClick={() => handleCopy(markdown, index)}
/>
</Stack>
</Flex>
</Card>
)
})}
</Stack>
)}
</Stack>
</Card>
)
}
What I was trying to accomplish: I needed a way to see all my uploaded images as thumbnails, with the optimized URLs ready to copy. The key was making it collapsible so it wouldn't take up too much space in the Sanity interface.
The tricky parts explained:
imageUrlBuilder()
is Sanity's magic for creating optimized image URLs - you can specify width, quality, format, etc.- I had to create a separate
browserClient
without server tokens because mixing server and browser contexts causes security issues navigator.clipboard.writeText()
gives you that satisfying one-click copy functionality- The error handling is crucial because Sanity asset references can come in different formats (
_ref
,_id
, or direct) width(800).quality(80)
gives me web-optimized images that load fast but still look good
⚠️ Gotcha I Hit: Initially, I kept getting "undefined" in my URLs. Turns out Sanity asset references aren't always in the same format, so I had to handle multiple cases with
image.asset._ref || image.asset._id || image.asset
.
Step 3: Creating the Gallery Input Wrapper
Now I needed to combine Sanity's default image upload interface with my URL helper. This wrapper component does exactly that.
Where I put this: src/sanity/components/GalleryInput.tsx
'use client'
import React from 'react'
import { Stack } from '@sanity/ui'
import { ArrayOfObjectsInputProps } from 'sanity'
import { GalleryUrlHelper } from './GalleryUrlHelper'
export function GalleryInput(props: ArrayOfObjectsInputProps) {
const { value, renderDefault } = props
return (
<Stack space={4}>
{renderDefault(props)}
<GalleryUrlHelper images={(value || []) as any} />
</Stack>
)
}
What I was going for: I wanted to keep all of Sanity's built-in upload functionality (drag & drop, file browser, etc.) but add my URL helper right below it. This way I get the best of both worlds.
The key concepts:
renderDefault(props)
is Sanity's way of saying "render the normal interface here"ArrayOfObjectsInputProps
is the TypeScript type for array field inputs in Sanity v3value
contains whatever images have been uploaded so farStack
just gives me nice vertical spacing between the upload area and URL helper
This is a pretty simple wrapper, but it's the glue that makes everything work together seamlessly.
Step 4: Building the Gallery Picker Modal
This was the fun part - creating a modal that would pop up from the markdown editor and let me click on any gallery image to insert it right at my cursor.
Where I built this: src/sanity/components/GalleryPickerModal.tsx
'use client'
import React from 'react'
import { Dialog, Button, Card, Grid, Stack, Text, Box, Flex } from '@sanity/ui'
import { CloseIcon } from '@sanity/icons'
import imageUrlBuilder from '@sanity/image-url'
import { createClient } from 'next-sanity'
import { apiVersion, dataset, projectId } from '@/sanity/env'
interface GalleryImage {
_type: 'image'
asset: any
alt?: string
caption?: string
}
interface GalleryPickerModalProps {
isOpen: boolean
onClose: () => void
images: GalleryImage[]
onImageSelect: (markdown: string) => void
}
export function GalleryPickerModal({ isOpen, onClose, images, onImageSelect }: GalleryPickerModalProps) {
// Create a client without server-side token for browser use
const browserClient = createClient({
projectId,
dataset,
apiVersion,
useCdn: true,
})
const builder = imageUrlBuilder(browserClient)
const handleImageClick = (image: GalleryImage) => {
if (!image.asset) return
let optimizedUrl
try {
// Handle both resolved and unresolved asset references
const assetRef = image.asset._ref || image.asset._id || image.asset
optimizedUrl = builder.image(assetRef).width(800).quality(80).url()
} catch (error) {
console.error('Error building image URL:', error)
optimizedUrl = image.asset.url || 'undefined'
}
const altText = image.alt || 'Gallery image'
const markdown = ``
onImageSelect(markdown)
onClose()
}
if (!isOpen) return null
return (
<Dialog
id="gallery-picker"
onClose={onClose}
header="Select Gallery Image"
width={4}
>
<Box padding={4}>
<Stack space={4}>
<Flex align="center" justify="space-between">
<Text size={2} weight="semibold">
Choose an image to insert into your markdown
</Text>
<Button
icon={CloseIcon}
mode="ghost"
onClick={onClose}
title="Close"
/>
</Flex>
{images.length === 0 ? (
<Card padding={4} tone="transparent" border>
<Text align="center" muted>
No gallery images available. Upload images to the gallery first.
</Text>
</Card>
) : (
<Grid columns={3} gap={3}>
{images.map((image, index) => {
if (!image.asset) return null
const altText = image.alt || `Image ${index + 1}`
return (
<Card
key={index}
padding={2}
tone="default"
border
style={{ cursor: 'pointer' }}
onClick={() => handleImageClick(image)}
>
<Stack space={2}>
<img
src={(() => {
try {
const assetRef = image.asset._ref || image.asset._id || image.asset
return builder.image(assetRef).width(200).height(150).fit('crop').url()
} catch (error) {
return image.asset.url || 'undefined'
}
})()}
alt={altText}
style={{
width: '100%',
height: '120px',
objectFit: 'cover',
borderRadius: '4px'
}}
/>
<Text size={1} weight="medium">
{altText}
</Text>
{image.caption && (
<Text size={1} muted>
{image.caption}
</Text>
)}
</Stack>
</Card>
)
})}
</Grid>
)}
</Stack>
</Box>
</Dialog>
)
}
What I wanted to achieve: I needed a clean modal that shows all my gallery images in a grid, where I can click any image and have it automatically insert the markdown syntax right where my cursor was in the editor.
The important pieces:
Dialog
from Sanity UI gives me a nice modal out of the boxGrid columns={3}
creates a responsive grid that looks good on different screen sizes- The inline function for generating image
src
handles all the URL building and error cases onImageSelect(markdown)
is the callback that sends the generated markdown back to the editorcursor: 'pointer'
makes it obvious that the images are clickable
The flow is: click button → modal opens → click image → markdown gets inserted → modal closes. Simple but effective.
Step 5: Integrating Everything with the Markdown Editor
This was where everything came together. I needed to add a gallery button to my markdown editor toolbar and wire it up to insert images at the cursor position.
Where I updated this: My existing src/sanity/components/CustomMarkdownInput.tsx
'use client'
import { useMemo, useState } 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'
import { useFormValue } from 'sanity'
import { GalleryPickerModal } from './GalleryPickerModal'
export function CustomMarkdownInput(props: any) {
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false)
const [textAreaRef, setTextAreaRef] = useState<any>(null)
// Get gallery images from the current document
const galleryImages = useFormValue(['gallery']) as any[] || []
const insertImageAtCursor = (markdown: string) => {
if (!textAreaRef) return
// Try to work with CodeMirror if available
if ((textAreaRef as any).codemirror) {
const cm = (textAreaRef as any).codemirror
const doc = cm.getDoc()
const cursor = doc.getCursor()
doc.replaceRange(markdown, cursor)
cm.focus()
return
}
// Fallback to textarea if not CodeMirror
const textarea = (textAreaRef as any).element || textAreaRef
if (textarea && textarea.selectionStart !== undefined) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
const currentValue = textarea.value
const newValue = currentValue.substring(0, start) + markdown + currentValue.substring(end)
// Update the textarea value
textarea.value = newValue
// Trigger change event to update the form
const event = new Event('input', { bubbles: true })
textarea.dispatchEvent(event)
// Set cursor position after inserted text
const newPosition = start + markdown.length
textarea.setSelectionRange(newPosition, newPosition)
textarea.focus()
}
}
const reactMdeProps: MarkdownInputProps['reactMdeProps'] = useMemo(() => {
return {
options: {
toolbar: [
'bold', 'italic', 'heading', 'strikethrough', '|',
'quote', 'unordered-list', 'ordered-list', '|',
'link', 'image',
// Custom gallery button
{
name: 'gallery',
action: (editor: any) => {
// Store the editor instance for later use
setTextAreaRef(editor.codemirror || editor.element)
setIsGalleryModalOpen(true)
},
className: 'fa fa-images',
title: 'Insert Gallery Image',
},
'table', 'code', '|',
'preview', 'side-by-side', 'fullscreen', '|',
'guide'
],
autofocus: false,
spellChecker: true,
status: ['lines', 'words', 'cursor'],
previewRender: (markdownText) => {
const html = marked.parse(markdownText, {
async: false,
// @ts-ignore
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);
},
renderingConfig: {
singleLineBreaks: false,
codeSyntaxHighlighting: true,
},
uploadImage: true,
imageUploadFunction: undefined,
placeholder: 'Write your content in Markdown...\n\nTip: Use the gallery button 🖼️ to insert images from your gallery.',
tabSize: 2,
minHeight: '300px',
},
}
}, [])
return (
<>
<MarkdownInput {...props} reactMdeProps={reactMdeProps} />
<GalleryPickerModal
isOpen={isGalleryModalOpen}
onClose={() => setIsGalleryModalOpen(false)}
images={galleryImages}
onImageSelect={insertImageAtCursor}
/>
</>
)
}
What I was trying to accomplish: I needed to add a gallery button to the toolbar, pull in the gallery images from the current document, and handle inserting markdown at exactly where the cursor was positioned.
The tricky parts I had to figure out:
useFormValue(['gallery'])
is how you access other fields from the same document in Sanity- I had to capture the editor instance only when the gallery button is clicked, not during setup
insertImageAtCursor
was the hardest part - I had to handle both CodeMirror (fancy editor) and plain textarea (fallback)doc.replaceRange(markdown, cursor)
is CodeMirror's way of inserting text at the cursor- For plain textareas, I had to manually handle selection ranges and dispatch events
💡 Why the dual handling: Different markdown editor implementations use different underlying tech. CodeMirror is fancy but sometimes you get a plain textarea.
⚠️ Bug I hit: I initially tried to capture the editor reference during component setup with
textAreaProps
, which completely broke the toolbar buttons. The fix was to only grab the editor reference when the gallery button is actually clicked.
Step 6: Adding Gallery Count to Post Previews (Optional)
I wanted to see at a glance which posts had gallery images, so I added the image count to the post previews in Sanity's list view.
Where I added this: In my post schema's preview configuration
preview: {
select: {
title: 'title',
subtitle: 'subtitle',
author: 'author.name',
media: 'mainImage',
isHowTo: 'isHowTo',
gallery: 'gallery',
},
prepare(selection) {
const { author, subtitle, isHowTo, gallery } = selection
let subtitleText = ''
if (isHowTo) {
subtitleText = '📋 How-To Guide'
} else if (subtitle) {
subtitleText = subtitle
} else if (author) {
subtitleText = `by ${author}`
}
// Add gallery count if images exist
if (gallery && gallery.length > 0) {
const galleryText = `${gallery.length} gallery image${gallery.length === 1 ? '' : 's'}`
subtitleText = subtitleText ? `${subtitleText} • ${galleryText}` : galleryText
}
return {...selection, subtitle: subtitleText}
},
},
What this accomplishes: This gives me a quick visual indicator in the post list showing how many gallery images each post has. It's a small touch but helps me see which posts are more image-heavy at a glance.
Step 7: Installing the Dependencies I Needed
I had to make sure I had all the required packages installed:
npm install @sanity/ui @sanity/client @sanity/image-url
Note: If you need Font Awesome for the gallery button icon, install it too:
npm install @fortawesome/fontawesome-free
Then import it in your main CSS file:
@import '@fortawesome/fontawesome-free/css/all.css';
Step 8: Deploying the Schema Changes
After building everything, I needed to generate new TypeScript types and deploy the schema changes:
npx sanity schema extract npx sanity typegen generate npm run build
Step 9: Testing Your Implementation
To make sure everything works:
- Upload Test Images: Go to your post in Sanity Studio and add some images to the new gallery field
- Check URL Helper: Expand the URL helper below the gallery - you should see markdown-ready URLs
- Test Copy Function: Click "Copy Markdown" on any image and paste it somewhere
- Test Gallery Picker: In the markdown editor, click the gallery button (🖼️) - the modal should open
- Test Insertion: Click an image in the modal - it should insert at your cursor position
If any step fails, check the browser console for errors and refer to the troubleshooting section below.
Problems I Ran Into and How I Fixed Them
Issue 1: "addRange(): The given range isn't in document" Error
What happened: My markdown editor toolbar buttons completely stopped working after I added the gallery functionality.
Why it broke: I made the mistake of trying to mess with the editor's DOM management by adding custom textAreaProps
during component initialization. This interfered with how the editor managed its own DOM.
How I fixed it: I changed my approach to only capture the editor reference when someone actually clicks the gallery button, not during setup.
// ❌ Wrong approach - interferes with editor
textAreaProps: {
ref: (ref: HTMLTextAreaElement) => setTextAreaRef(ref),
}
// ✅ Correct approach - capture on demand
action: (editor: any) => {
setTextAreaRef(editor.codemirror || editor.element)
setIsGalleryModalOpen(true)
}
Issue 2: Getting "undefined" in My Image URLs
What happened: Instead of proper URLs, I kept getting 
in my generated markdown.
Why it happened: Sanity asset references aren't consistent - sometimes you get _ref
, sometimes _id
, sometimes the direct asset object, depending on when and how they're resolved.
How I solved it: I added fallback handling to try different asset reference formats:
// Handle different asset reference formats
const assetRef = image.asset._ref || image.asset._id || image.asset
Debugging tip I used: I added console logging to see what the asset structure actually looked like:
console.log('Image asset:', image.asset)
Issue 3: Server-Side Token Problems
What happened: My image URL builder kept failing when running in the browser, even though it worked fine on the server.
Why it failed: I was trying to use my server-side Sanity client (which has tokens) in browser components, which creates security issues.
How I fixed it: I created a separate client specifically for browser use without any server tokens:
const browserClient = createClient({
projectId,
dataset,
apiVersion,
useCdn: true, // No token needed for public assets
})
✅ Best Practice: Always use separate clients for browser vs server contexts to avoid security issues.
How Everything Works Together
Here's the complete flow I ended up with:
-
My Writing Workflow:
- I upload images to the gallery field in Sanity
- I can see optimized URLs right there in a collapsible helper
- I can copy markdown syntax with one click
- Or I can use the gallery button right in the markdown editor
-
What Happens Under the Hood:
- The gallery field stores my image assets with proper metadata
- The URL helper generates optimized CDN URLs automatically
- The gallery picker integrates seamlessly with my markdown editor
- All images get optimized for web delivery without me thinking about it
-
What This Gives Me:
- No more manual URL construction
- Automatic image optimization
- Smooth markdown writing experience
- Visual image selection that actually works
Wrapping Up
Building this feature solved a real pain point in my content creation workflow. The key insight was creating a bridge between Sanity's powerful asset management and my markdown writing experience, while handling all the weird edge cases around asset references and editor integration.
I kept everything modular so each component can be reused or tweaked independently. This makes it easy to adapt this solution to different use cases or add new features later. The whole thing just works seamlessly now - I upload images, click a button, and get perfectly formatted markdown. Exactly what I wanted.
Thanks, Matija