CoursesMarkdown Routes with Next.jsSet up markdown-ready docs with Next.js and Sanity
Markdown Routes with Next.js

Set up markdown-ready docs with Next.js and Sanity

Extend your existing Next.js + Sanity project with schemas, queries, and routing structure needed to export documentation content as markdown files alongside your HTML pages.
Log in to mark your progress for each Lesson and Task

Let's extend your Next.js project with the structure we need for markdown export.

  • Add @portabletext/markdown to your existing Next.js + Sanity project
  • Create the schema: sections and pages
  • Plan the Route Handler structure
  • Understand how next.config.ts rewrites will route requests

This lesson assumes you have a working Next.js project with Sanity integration — similar to what you'd have after completing the "Content-driven web application foundations" course.

Requirements:

  • Node.js 20+
  • Next.js 16.x
  • React 19+ (use react@latest if you encounter peer dependency warnings with Sanity packages)
If you're starting fresh, create a new project:
npx create-next-app@latest keplar-docs --typescript --tailwind --app --src-dir
cd keplar-docs
Install the Portable Text to Markdown library:
npm install @portabletext/markdown
You should already have these from your Sanity setup, but verify they're installed:
npm install next-sanity @sanity/client @portabletext/react

Our documentation site uses a simple two-level hierarchy: Sections contain Articles.

Create the schema file:
import { defineField, defineType } from 'sanity'
// Section schema - top-level groupings like "Getting Started", "API Reference"
export const section = defineType({
name: 'section',
title: 'Section',
type: 'document',
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'string',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: { source: 'title' },
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'description',
title: 'Description',
type: 'text',
}),
defineField({
name: 'order',
title: 'Order',
type: 'number',
description: 'Display order in navigation',
}),
],
})
// Article schema - individual documentation articles
export const article = defineType({
name: 'article',
title: 'Article',
type: 'document',
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'string',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: { source: 'title' },
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'section',
title: 'Section',
type: 'reference',
to: [{ type: 'section' }],
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'summary',
title: 'Summary',
type: 'text',
description: 'Brief description for listings and SEO',
}),
defineField({
name: 'content',
title: 'Content',
type: 'array',
of: [
{ type: 'block' },
{ type: 'code' },
{ type: 'image' },
{ type: 'callout' },
],
}),
defineField({
name: 'order',
title: 'Order',
type: 'number',
description: 'Display order within section',
}),
],
})
// Code block for documentation
export const codeBlock = defineType({
name: 'code',
title: 'Code Block',
type: 'object',
fields: [
defineField({
name: 'code',
title: 'Code',
type: 'text',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'language',
title: 'Language',
type: 'string',
options: {
list: [
{ title: 'JavaScript', value: 'javascript' },
{ title: 'TypeScript', value: 'typescript' },
{ title: 'Bash', value: 'bash' },
{ title: 'JSON', value: 'json' },
],
},
}),
defineField({
name: 'filename',
title: 'Filename',
type: 'string',
}),
],
})
// Callout for tips, warnings, notes
export const callout = defineType({
name: 'callout',
title: 'Callout',
type: 'object',
fields: [
defineField({
name: 'type',
title: 'Type',
type: 'string',
options: {
list: [
{ title: 'Note', value: 'note' },
{ title: 'Tip', value: 'tip' },
{ title: 'Warning', value: 'warning' },
],
},
initialValue: 'note',
}),
defineField({
name: 'content',
title: 'Content',
type: 'array',
of: [{ type: 'block' }],
}),
],
})
export const schemaTypes = [section, article, codeBlock, callout]
Create the client configuration if you haven’t already:
import { createClient } from 'next-sanity'
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || 'production',
apiVersion: '2024-01-01',
useCdn: false,
})
Add your environment variables:
NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-id
NEXT_PUBLIC_SANITY_DATASET=production
NEXT_PUBLIC_SITE_URL=http://localhost:3000
The NEXT_PUBLIC_SANITY_PROJECT_ID and NEXT_PUBLIC_SANITY_DATASET variables are also used by the markdown serializers to build image CDN URLs. Make sure these match your Sanity project settings.

Here's the file structure we'll build:

src/app/
├── docs/
│ └── [section]/
│ └── [article]/
│ └── page.tsx # HTML pages (Server Components)
├── md/
│ └── [section]/
│ ├── route.ts # Section markdown Route Handler
│ └── [article]/
│ └── route.ts # Article markdown Route Handler
├── sitemap.md/
│ └── route.ts # Sitemap Route Handler
└── next.config.ts # Rewrites for .md URLs and content negotiation

Key concepts:

  1. /docs/[section]/[article]/page.tsx — Standard Next.js pages for HTML output
  2. /md/[section]/route.ts — Route Handler for section markdown
  3. /md/[section]/[article]/route.ts — Route Handler for article markdown
  4. /sitemap.md/route.ts — Route Handler for the markdown sitemap
The /md/ routes are internal — users access them via .md suffix URLs (like /docs/getting-started/intro.md) which are rewritten by next.config.ts.

A Page (page.tsx) returns JSX that Next.js renders to HTML.

A Route Handler (route.ts) returns a raw Response object — you control the exact output. This is what we need for markdown.

// Page - returns JSX
export default function Page() {
return <div>Hello</div>
}
// Route Handler - returns Response
export function GET() {
return new Response('# Hello', {
headers: { 'Content-Type': 'text/markdown' }
})
}

[section] and [article] are dynamic segments that capture URL parameters:

  • /md/getting-started{ section: 'getting-started' }
  • /md/getting-started/quickstart{ section: 'getting-started', article: 'quickstart' }

We use separate Route Handlers for sections and articles rather than a catch-all, which makes the code clearer.

We'll need queries to fetch content.

Create a queries file:
import { defineQuery } from 'next-sanity'
// Get a single article by section and article slug
export const ARTICLE_QUERY = defineQuery(`
*[_type == "article" && slug.current == $articleSlug && section->slug.current == $sectionSlug][0] {
_id,
title,
slug,
summary,
content,
"section": section-> {
_id,
title,
slug
}
}
`)
// Get a section with its articles
export const SECTION_QUERY = defineQuery(`
*[_type == "section" && slug.current == $sectionSlug][0] {
_id,
title,
slug,
description,
"articles": *[_type == "article" && section._ref == ^._id] | order(order asc) {
_id,
title,
slug,
summary
}
}
`)
// Get all sections with their articles (for sitemap)
export const SITEMAP_QUERY = defineQuery(`
*[_type == "section"] | order(order asc) {
_id,
title,
slug,
description,
"articles": *[_type == "article" && section._ref == ^._id] | order(order asc) {
_id,
title,
slug,
summary
}
}
`)
// Get an article with navigation context (prev/next articles)
export const ARTICLE_WITH_NAV_QUERY = defineQuery(`
{
"article": *[_type == "article" && slug.current == $articleSlug && section->slug.current == $sectionSlug][0] {
_id,
title,
slug,
summary,
content,
"section": section-> {
_id,
title,
slug
}
},
"allArticles": *[_type == "article" && section->slug.current == $sectionSlug] | order(order asc) {
_id,
title,
slug,
order
}
}
`)

At this point you should be able to:

  • Run npm run dev without errors
  • See your schema types in Sanity Studio (if configured)
  • Have the folder structure planned for routes
npm run dev

If you see the Next.js welcome page, you're ready for the next lesson.

In the next lesson, we'll build the Portable Text to Markdown serializers — the core logic that converts Sanity content to markdown.

You have 7 uncompleted tasks in this lesson
0 of 7