CoursesMarkdown Routes with Next.jsPortable Text to Markdown
Markdown Routes with Next.js

Portable Text to Markdown

Sanity stores content as Portable Text — now we need to turn it into markdown.
Log in to mark your progress for each Lesson and Task
  • Understand Portable Text structure
  • Use @portabletext/markdown for conversion
  • Handle standard blocks and marks
  • Create custom serializers for code blocks, images, and callouts

Portable Text is an open source specification for block content and rich text format. Unlike HTML strings, it's structured data — an array of blocks that you can render and query however you want.

Here's a simple example:

[
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Hello ",
"marks": []
},
{
"_type": "span",
"text": "world",
"marks": ["strong"]
}
]
}
]

This same content can render as:

  • HTML: <p>Hello <strong>world</strong></p>
  • Markdown: Hello **world**
  • Plain text: Hello world

The @portabletext/markdown library handles this markdown conversion for us.

Import and use the converter:
import { portableTextToMarkdown } from '@portabletext/markdown'
import type { PortableTextBlock } from '@portabletext/types'
const blocks: PortableTextBlock[] = [
{
_type: 'block',
style: 'h2',
children: [{ _type: 'span', text: 'Hello World' }],
},
{
_type: 'block',
style: 'normal',
children: [{ _type: 'span', text: 'This is a paragraph.' }],
},
]
const markdown = portableTextToMarkdown(blocks)
console.log(markdown)
// ## Hello World
//
// This is a paragraph.

The library automatically handles:

  • Paragraphs → plain text
  • Headings → ##, ###, ####
  • Bold → **text**
  • Italic → *text*
  • Links → [text](url)
  • Lists → - or 1.
  • Blockquotes → >

The schema includes custom block types that need custom serializers.

Create a file that handles code blocks, images, and callouts:
import { portableTextToMarkdown } from '@portabletext/markdown'
import type {
PortableTextBlock,
PortableTextMarkDefinition,
} from '@portabletext/types'
import imageUrlBuilder from '@sanity/image-url'
import { client } from '@/sanity/lib/client'
const builder = imageUrlBuilder(client)
// Custom block types from your schema
type CodeBlock = {
_type: 'code'
language?: string
filename?: string
code: string
}
type ImageBlock = {
_type: 'image'
asset: { _ref: string }
alt?: string
caption?: string
}
type CalloutBlock = {
_type: 'callout'
style?: 'note' | 'tip' | 'important' | 'warning' | 'caution'
content: PortableTextBlock[]
}
type CustomBlock = CodeBlock | ImageBlock | CalloutBlock
/**
* Convert Portable Text to markdown with custom serializers
*/
export function convertToMarkdown(
blocks: (PortableTextBlock | CustomBlock)[],
): string {
return portableTextToMarkdown(blocks, {
serializers: {
types: {
code: ({ value }: { value: CodeBlock }) => {
const { language = '', filename, code } = value
const lang = filename ? `${language}:${filename}` : language
return `\`\`\`${lang}\n${code}\n\`\`\``
},
image: ({ value }: { value: ImageBlock }) => {
const url = builder.image(value.asset).url()
const alt = value.alt || ''
const caption = value.caption
? `\n\n*${value.caption}*`
: ''
return `![${alt}](${url})${caption}`
},
callout: ({ value }: { value: CalloutBlock }) => {
const style = value.style || 'note'
const content = portableTextToMarkdown(value.content)
// GitHub Flavored Markdown alerts
const prefix = `[!${style.toUpperCase()}]`
return `> ${prefix}\n> ${content.replace(/\n/g, '\n> ')}`
},
},
},
})
}

Code blocks render with language and optional filename:

```typescript:src/lib/example.ts
export function hello() {
return 'world'
}
```

Callouts use GitHub Flavored Markdown alert syntax:

> [!TIP]
> Use `pnpm` for faster installs.
> [!WARNING]
> This will delete all your data.
> [!NOTE]
> This is informational.

Internal links need to be converted to absolute URLs since agents are bringing this content outside of your website.

Add a custom mark serializer for internal links:
/**
* Normalize internal links to absolute URLs
*/
export function normalizeUrl(href: string, baseUrl: string): string {
// Already absolute
if (href.startsWith('http://') || href.startsWith('https://')) {
return href
}
// Root-relative
if (href.startsWith('/')) {
return `${baseUrl}${href}`
}
// Relative path
return `${baseUrl}/${href}`
}
// Add to convertToMarkdown options:
export function convertToMarkdown(
blocks: (PortableTextBlock | CustomBlock)[],
baseUrl: string = 'https://sanity.io',
): string {
return portableTextToMarkdown(blocks, {
serializers: {
types: {
// ... existing serializers
},
marks: {
internalLink: ({ value, children }: any) => {
const href = normalizeUrl(value.href, baseUrl)
return `[${children}](${href})`
},
},
},
})
}
Create a helper that builds a complete markdown document with metadata:
import { convertToMarkdown } from './markdownSerializers'
import type { PortableTextBlock } from '@portabletext/types'
type ArticleData = {
title: string
slug: string
description?: string
content: PortableTextBlock[]
section?: {
title: string
slug: string
}
prevArticle?: {
title: string
slug: string
}
nextArticle?: {
title: string
slug: string
}
}
/**
* Build complete article markdown with navigation context
*/
export function buildArticleMarkdown(
article: ArticleData,
baseUrl: string,
): string {
const parts: string[] = []
// Title
parts.push(`# ${article.title}`)
parts.push('')
// Canonical URL
parts.push(`**URL:** ${baseUrl}/${article.slug}`)
parts.push('')
// Navigation context
if (article.section) {
parts.push(`**Section:** [${article.section.title}](${baseUrl}/${article.section.slug})`)
}
const navLinks: string[] = []
if (article.prevArticle) {
navLinks.push(`← [${article.prevArticle.title}](${baseUrl}/${article.prevArticle.slug})`)
}
if (article.nextArticle) {
navLinks.push(`[${article.nextArticle.title}](${baseUrl}/${article.nextArticle.slug}) →`)
}
if (navLinks.length > 0) {
parts.push(`**Navigation:** ${navLinks.join(' | ')}`)
}
parts.push('')
parts.push('---')
parts.push('')
// Summary
if (article.description) {
parts.push(`**Summary:** ${article.description}`)
parts.push('')
}
// Content
const content = convertToMarkdown(article.content, baseUrl)
parts.push(content)
return parts.join('\n')
}
For section listing pages, add this
type SectionData = {
title: string
slug: string
description?: string
articles: Array<{
title: string
slug: string
description?: string
}>
}
/**
* Build section markdown with article list
*/
export function buildSectionMarkdown(
section: SectionData,
baseUrl: string,
): string {
const parts: string[] = []
// Section header
parts.push(`# ${section.title}`)
parts.push('')
parts.push(`**URL:** ${baseUrl}/${section.slug}`)
parts.push('')
if (section.description) {
parts.push(section.description)
parts.push('')
}
parts.push('---')
parts.push('')
// Article list
parts.push('## Articles in this section')
parts.push('')
for (const article of section.articles) {
parts.push(`### [${article.title}](${baseUrl}/${article.slug})`)
if (article.description) {
parts.push('')
parts.push(article.description)
}
parts.push('')
}
return parts.join('\n')
}
Add this for the content discovery and navigation:
type SitemapData = {
categories: Array<{
title: string
slug: string
sections: Array<{
title: string
slug: string
articles: Array<{
title: string
slug: string
}>
}>
}>
}
/**
* Build sitemap markdown for content discovery
*/
export function buildSitemapMarkdown(
sitemap: SitemapData,
baseUrl: string,
): string {
const parts: string[] = []
parts.push('# Content Sitemap')
parts.push('')
parts.push('Complete navigation structure of all content.')
parts.push('')
parts.push('**Access any content as markdown:**')
parts.push(`Add \`?format=markdown\` to any URL: \`${baseUrl}/[slug]?format=markdown\``)
parts.push('')
parts.push('---')
parts.push('')
for (const category of sitemap.categories) {
parts.push(`## [${category.title}](${baseUrl}/${category.slug})`)
parts.push('')
for (const section of category.sections) {
parts.push(`### [${section.title}](${baseUrl}/${section.slug})`)
parts.push('')
for (const article of section.articles) {
parts.push(`- [${article.title}](${baseUrl}/${article.slug})`)
}
parts.push('')
}
}
return parts.join('\n')
}
Add a test with sample blocks:
import { convertToMarkdown } from '../markdownSerializers'
const testBlocks = [
{
_type: 'block',
style: 'h2',
children: [{ _type: 'span', text: 'Getting Started' }],
},
{
_type: 'block',
style: 'normal',
children: [{ _type: 'span', text: 'Install the package:' }],
},
{
_type: 'code',
language: 'bash',
code: 'npm install @portabletext/markdown',
},
{
_type: 'callout',
style: 'tip',
content: [
{
_type: 'block',
style: 'normal',
children: [
{ _type: 'span', text: 'Use ' },
{ _type: 'span', text: 'pnpm', marks: ['code'] },
{ _type: 'span', text: ' for faster installs.' },
],
},
],
},
]
const markdown = convertToMarkdown(testBlocks)
console.log(markdown)
// Expected output:
// ## Getting Started
//
// Install the package:
//
// ```bash
// npm install @portabletext/markdown
// ```
//
// > [!TIP]
// > Use `pnpm` for faster installs.

Verification checklist:

  • Code blocks render with language and filename (typescript:filename.ts)
  • Images have full CDN URLs from Sanity
  • Callouts use GFM alert syntax (> [!NOTE])
  • Internal links are normalized to absolute URLs
  • Article markdown includes title, URL, navigation, and summary
  • Section markdown lists all articles with descriptions
  • Sitemap markdown shows complete navigation structure
You have 7 uncompleted tasks in this lesson
0 of 7