md0 CMS works seamlessly with Next.js. Your content stays in GitHub, and Next.js reads it directly—no API integration required. This guide covers setup for both App Router and Pages Router.
How It Works
The Architecture
GitHub Repository
├── content/
│ └── posts/ ← md0 CMS edits here
│ └── *.md
└── app/ (or pages/) ← Next.js reads from here
└── blog/
Workflow:
- Content team edits in md0 CMS
- Changes commit to GitHub
- Next.js reads markdown from filesystem
- No API calls needed
Quick Start
Prerequisites
- Next.js project (13+ recommended)
- GitHub repository
- Content in markdown format
Setup Steps
- Connect md0 CMS to your repository
- Create collections pointing to your content directories
- Configure Next.js to read markdown files
- Deploy and watch for changes
That's it. No API keys, no webhooks, no complex integration.
App Router (Next.js 13+)
Basic Blog Example
File structure:
your-blog/
├── content/
│ └── posts/
│ ├── first-post.md
│ └── second-post.md
└── app/
└── blog/
├── page.tsx
└── [slug]/
└── page.tsx
Install dependencies:
npm install gray-matter remark remark-html
List all posts (app/blog/page.tsx):
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import Link from "next/link";
const postsDirectory = path.join(process.cwd(), "content/posts");
function getPosts() {
const fileNames = fs.readdirSync(postsDirectory);
const posts = fileNames
.filter((file) => file.endsWith(".md"))
.map((fileName) => {
const fullPath = path.join(postsDirectory, fileName);
const fileContents = fs.readFileSync(fullPath, "utf8");
const { data, content } = matter(fileContents);
return {
slug: fileName.replace(/\.md$/, ""),
...data,
};
})
.sort((a, b) => (a.date < b.date ? 1 : -1));
return posts;
}
export default function BlogPage() {
const posts = getPosts();
return (
<div>
<h1>Blog</h1>
{posts.map((post) => (
<article key={post.slug}>
<Link href={`/blog/${post.slug}`}>
<h2>{post.title}</h2>
</Link>
<p>{post.excerpt}</p>
<time>{post.date}</time>
</article>
))}
</div>
);
}
Single post (app/blog/[slug]/page.tsx):
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import { remark } from "remark";
import html from "remark-html";
const postsDirectory = path.join(process.cwd(), "content/posts");
async function getPost(slug: string) {
const fullPath = path.join(postsDirectory, `${slug}.md`);
const fileContents = fs.readFileSync(fullPath, "utf8");
const { data, content } = matter(fileContents);
const processedContent = await remark().use(html).process(content);
const contentHtml = processedContent.toString();
return {
slug,
contentHtml,
...data,
};
}
export async function generateStaticParams() {
const fileNames = fs.readdirSync(postsDirectory);
return fileNames
.filter((file) => file.endsWith(".md"))
.map((fileName) => ({
slug: fileName.replace(/\.md$/, ""),
}));
}
export default async function Post({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return (
<article>
<h1>{post.title}</h1>
<time>{post.date}</time>
<div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
</article>
);
}
With MDX Support
Install next-mdx-remote:
npm install next-mdx-remote
Render MDX (app/blog/[slug]/page.tsx):
import { MDXRemote } from "next-mdx-remote/rsc";
import matter from "gray-matter";
import fs from "fs";
import path from "path";
async function getPost(slug: string) {
const fullPath = path.join(process.cwd(), "content/posts", `${slug}.mdx`);
const source = fs.readFileSync(fullPath, "utf8");
const { data, content } = matter(source);
return { data, content };
}
export default async function Post({ params }: { params: { slug: string } }) {
const { data, content } = await getPost(params.slug);
return (
<article>
<h1>{data.title}</h1>
<MDXRemote source={content} />
</article>
);
}
Pages Router
Basic Blog Example
List posts (pages/blog/index.tsx):
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import Link from "next/link";
import { GetStaticProps } from "next";
interface Post {
slug: string;
title: string;
date: string;
excerpt: string;
}
export const getStaticProps: GetStaticProps = async () => {
const postsDirectory = path.join(process.cwd(), "content/posts");
const fileNames = fs.readdirSync(postsDirectory);
const posts = fileNames
.filter((file) => file.endsWith(".md"))
.map((fileName) => {
const fullPath = path.join(postsDirectory, fileName);
const fileContents = fs.readFileSync(fullPath, "utf8");
const { data } = matter(fileContents);
return {
slug: fileName.replace(/\.md$/, ""),
...data,
} as Post;
})
.sort((a, b) => (a.date < b.date ? 1 : -1));
return {
props: { posts },
};
};
export default function BlogPage({ posts }: { posts: Post[] }) {
return (
<div>
<h1>Blog</h1>
{posts.map((post) => (
<article key={post.slug}>
<Link href={`/blog/${post.slug}`}>
<h2>{post.title}</h2>
</Link>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
Single post (pages/blog/[slug].tsx):
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import { remark } from "remark";
import html from "remark-html";
import { GetStaticProps, GetStaticPaths } from "next";
export const getStaticPaths: GetStaticPaths = async () => {
const postsDirectory = path.join(process.cwd(), "content/posts");
const fileNames = fs.readdirSync(postsDirectory);
const paths = fileNames
.filter((file) => file.endsWith(".md"))
.map((fileName) => ({
params: { slug: fileName.replace(/\.md$/, "") },
}));
return {
paths,
fallback: false,
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const fullPath = path.join(
process.cwd(),
"content/posts",
`${params?.slug}.md`
);
const fileContents = fs.readFileSync(fullPath, "utf8");
const { data, content } = matter(fileContents);
const processedContent = await remark().use(html).process(content);
const contentHtml = processedContent.toString();
return {
props: {
post: {
slug: params?.slug,
contentHtml,
...data,
},
},
};
};
export default function Post({ post }: any) {
return (
<article>
<h1>{post.title}</h1>
<time>{post.date}</time>
<div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
</article>
);
}
Advanced Features
Incremental Static Regeneration (ISR)
Rebuild pages on a schedule:
// App Router
export const revalidate = 3600; // Revalidate every hour
// Pages Router
export const getStaticProps: GetStaticProps = async () => {
// ...
return {
props: { posts },
revalidate: 3600, // Revalidate every hour
};
};
On-Demand Revalidation
Trigger rebuilds via webhook or API:
// app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
import { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get("secret");
if (secret !== process.env.REVALIDATE_SECRET) {
return new Response("Invalid token", { status: 401 });
}
try {
revalidatePath("/blog");
return Response.json({ revalidated: true });
} catch (err) {
return Response.json({ revalidated: false }, { status: 500 });
}
}
Image Optimization
Use Next.js Image component with markdown images:
import Image from "next/image";
import { MDXRemote } from "next-mdx-remote/rsc";
const components = {
img: (props: any) => (
<Image
src={props.src}
alt={props.alt}
width={800}
height={600}
className="rounded-lg"
/>
),
};
export default async function Post({ params }: any) {
const { content } = await getPost(params.slug);
return (
<article>
<MDXRemote source={content} components={components} />
</article>
);
}
TypeScript Types
Define types for your frontmatter:
interface PostFrontmatter {
title: string;
date: string;
excerpt: string;
author: string;
tags: string[];
featured_image?: string;
published: boolean;
}
interface Post extends PostFrontmatter {
slug: string;
content: string;
}
Content Libraries
With Contentlayer
Install:
npm install contentlayer next-contentlayer
Configure (contentlayer.config.ts):
import { defineDocumentType, makeSource } from "contentlayer/source-files";
export const Post = defineDocumentType(() => ({
name: "Post",
filePathPattern: `posts/**/*.md`,
fields: {
title: { type: "string", required: true },
date: { type: "date", required: true },
excerpt: { type: "string", required: true },
},
computedFields: {
slug: {
type: "string",
resolve: (post) => post._raw.flattenedPath.replace("posts/", ""),
},
},
}));
export default makeSource({
contentDirPath: "content",
documentTypes: [Post],
});
Use in Next.js:
import { allPosts } from "contentlayer/generated";
export default function BlogPage() {
const posts = allPosts.sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
return (
<div>
{posts.map((post) => (
<article key={post.slug}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
With Velite
Similar to Contentlayer but actively maintained:
npm install velite
Configuration is similar - check Velite docs.
Deployment
Vercel
md0 CMS works perfectly with Vercel:
- Connect GitHub repository to Vercel
- Set build command:
npm run build - Deploy
When you save in md0 CMS:
- Commits to GitHub
- Triggers Vercel build
- Site updates automatically
Netlify
Same workflow:
- Connect repository
- Set build command
- Deploy
Other Hosts
Works with any host that supports:
- GitHub integration
- Static site generation
- Automatic deployments
Best Practices
Content Organization
content/
├── posts/
│ ├── 2024/
│ │ └── my-post.md
│ └── 2023/
└── pages/
└── about.md
Metadata
Define consistent frontmatter:
---
title: "Post Title"
date: 2024-01-15
excerpt: "Brief summary"
author: "Author Name"
tags: [tag1, tag2]
published: true
---
Image Paths
Use absolute paths from public directory:

Maps to: public/images/my-image.jpg
Troubleshooting
Content Not Updating
Check:
- Commits are in GitHub
- Deployment triggered
- Build succeeded
- Cache cleared
Images Not Loading
Check:
- Images in
public/directory - Paths are correct
- Image optimization configured
- File names match exactly
Next Steps
- Create Collections for your Next.js content
- Define Schemas matching your frontmatter
- Start Editing in md0 CMS