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.ts
We 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-time
In 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.json
Add 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.ts
Wrap 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 DocsPage
Start your dev server:
npm run dev
Visit:
http://localhost:3000/docs
You’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.
Configuring PowerShell (shell prompt)
Markdown Syntax Guide
Custom Content Code Block Using HTML CSS JS