As engineering leads, one of our recurring challenges is balancing scalability, readability, and extensibility when building content-heavy frontend applications. A lightweight, composable blog system using Next.js (Pages Router) and Contentlayer checks all the right boxes—type safety, statically generated performance, and full MDX control.
In this post, you'll set up a production-grade foundation for a statically rendered blog using Contentlayer and Next.js, with an emphasis on correctness, long-term maintainability, and modern DX.
Here’s the minimal directory layout you’ll be working with:
.
├── public/
│ └── content/
│ └── blog/
│ └── markdown-syntax-guide/
│ └── index.mdx
├── contentlayer.config.ts
├── tsconfig.json
└── next.config.tsWe place content under public/content/ for long-term portability (e.g., CDN assets), though contentlayer supports custom locations.
Install the following packages to enable MDX parsing, syntax highlighting, and advanced remark/rehype plugins:
npm install \
contentlayer next-contentlayer \
shiki rehype-shiki rehype-pretty-code \
rehype-autolink-headings rehype-slug rehype-rewrite rehype-stringify \
remark-gfm unified \
date-fns reading-timeIn your root, create a contentlayer.config.ts file:
import { defineDocumentType, makeSource } from '@contentlayer/source-files'
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
const Blog = defineDocumentType(() => ({
name: 'Blog',
filePathPattern: 'blog/**/*.mdx',
contentType: 'mdx',
fields: {
title: { type: 'string', required: false },
publishedAt: { type: 'date', required: false },
description: { type: 'string', required: false },
isPublished: { type: 'boolean', default: false },
},
computedFields: {
url: {
type: 'string',
resolve: (doc) => `/${doc._raw.flattenedPath}`,
},
},
}))
export default makeSource({
contentDirPath: './public/content',
documentTypes: [Blog],
mdx: {
rehypePlugins: [
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'append' }],
],
},
})This schema gives you full control over per-document metadata while ensuring all types remain statically verifiable.
tsconfig.jsonAdd the generated types from Contentlayer:
{
"compilerOptions": {
"target": "ES2018",
"paths": {
"@/*": ["./src/*"],
"contentlayer/generated": ["./.contentlayer/generated"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}This ensures IDE autocompletion and compile-time checks for your MDX content.
next.config.tsWrap the config with withContentlayer for seamless integration:
import type { NextConfig } from 'next'
import { withContentlayer } from 'next-contentlayer'
const nextConfig: NextConfig = {
reactStrictMode: true,
}
export default withContentlayer(nextConfig)Place your first MDX file at: /public/content/blog/markdown-syntax-guide/index.mdx
---
title: "Markdown Syntax Guide"
publishedAt: "2022-04-09"
updatedAt: "2022-04-09"
description: List of markdown patterns and examples for structured writing in MDX.
author: "Gautam Ankoji"
username: "gautamankoji"
isPublished: true
tags:
- markdown
- syntax
---
Markdown is a lightweight markup language that allows you to format text in a plain-text editor while still producing structured and readable output. It’s widely used for documentation, blogging, and developer-focused content.Create a document listing view:
// pages/docs/index.tsx
import Link from 'next/link'
import { allDocuments } from 'contentlayer/generated'
const Docs = () => (
<div>
<h1>Docs</h1>
<ul>
{allDocuments.map((doc) => (
<li key={doc.url}>
<Link href={doc.url}>{doc.title}</Link>
</li>
))}
</ul>
</div>
)
export default Docs// pages/docs/[slug].tsx
import { allDocs, Doc } from 'contentlayer/generated'
import { useMDXComponent } from 'next-contentlayer/hooks'
const DocsPage = ({ doc }: { doc: Doc }) => {
const MDXContent = useMDXComponent(doc.body.code)
return (
<main>
<h1>{doc.title}</h1>
<MDXContent />
</main>
)
}
export async function getStaticPaths() {
return {
paths: allDocs.map((doc) => ({
params: { slug: doc._raw.flattenedPath.replace('docs/', '') },
})),
fallback: false,
}
}
export async function getStaticProps({ params }: { params: { slug: string } }) {
const doc = allDocs.find(
(doc) => doc._raw.flattenedPath === `docs/${params.slug}`
)
if (!doc) return { notFound: true }
return { props: { doc } }
}
export default DocsPageStart your dev server:
npm run devVisit:
http://localhost:3000/docsYou’ll see your content list. Click a link to view the full blog page.
This setup provides a robust foundation for building content-focused apps. By using Contentlayer with Next.js, you retain:
You can layer on features like full-text search, RSS feeds, dynamic theming, or analytics without disrupting the core.
In future iterations, consider extending this with a CMS like TinaCMS or pushing content management into a headless repo-backed system like [GitHub Issues-as-CMS].
Let your blog scale the way your codebase does—cleanly, predictably, and with type safety in every step.
Markdown Syntax Guide
Next.js (Pages Router) and Contentlayer Setup
Configuring PowerShell (shell prompt)