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/markdownfor 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 →
-or1. - 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 schematype 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 `${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.tsexport 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