Next.js maps folders in app/ or pages/ directly to routes, and md0 CMS collections mirror that same structure: a collection pointed at content/blog/*.md corresponds to files your Next.js code reads at build or request time with fs.readdirSync or a content library. 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
Note: Contentlayer is no longer actively maintained. Consider Velite below instead for new projects.
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