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.
Configuring PowerShell (shell prompt)
Markdown Syntax Guide
Custom Content Code Block Using HTML CSS JS