Set up markdown-ready docs with Next.js and Sanity
Let's extend your Next.js project with the structure we need for markdown export.
- Add
@portabletext/markdownto your existing Next.js + Sanity project - Create the schema: sections and pages
- Plan the Route Handler structure
- Understand how
next.config.tsrewrites 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@latestif you encounter peer dependency warnings with Sanity packages)
npx create-next-app@latest keplar-docs --typescript --tailwind --app --src-dircd keplar-docsnpm install @portabletext/markdownnpm install next-sanity @sanity/client @portabletext/reactOur documentation site uses a simple two-level hierarchy: Sections contain Articles.
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 articlesexport 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 documentationexport 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, notesexport 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]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,})NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-idNEXT_PUBLIC_SANITY_DATASET=productionNEXT_PUBLIC_SITE_URL=http://localhost:3000NEXT_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 negotiationKey concepts:
- /docs/[section]/[article]/page.tsx — Standard Next.js pages for HTML output
- /md/[section]/route.ts — Route Handler for section markdown
- /md/[section]/[article]/route.ts — Route Handler for article markdown
- /sitemap.md/route.ts — Route Handler for the markdown sitemap
/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 JSXexport default function Page() { return <div>Hello</div>}
// Route Handler - returns Responseexport 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.
import { defineQuery } from 'next-sanity'
// Get a single article by section and article slugexport 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 articlesexport 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 devwithout errors - See your schema types in Sanity Studio (if configured)
- Have the folder structure planned for routes
npm run devIf 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.