There are many ways to host blogs with Next.js but I needed something fast & simple: plain MDX files, first‑party support, and zero extra content pipelines. No Contentlayer (which is unmaintained). No next-mdx-remote
. No heavy weighted CMS systems.
TL;DR
- Next.js’s official MDX integration lets you import
.mdx
as components and exportmetadata
alongside content.- See doc here: https://nextjs.org/docs/pages/guides/mdx
- I used
@next/mdx
with the App Router and kept indexing simple by importing metadata directly from each MDX file. - No extra build steps, no content database, and the sitemap pulls dates straight from the MDX front‑matter.
Why MDX + App Router?
-
Content is code: The App Router treats a folder as a route and
page.mdx
as a component. You get layouts, streaming, and RSC benefits for free. -
First‑party MDX: The official plugin is maintained with Next.js and plays nicely with routing, metadata, and bundling.
-
Lower cognitive load: For a small product site, I don’t want a content compiler, watcher, or a GraphQL layer. A few MDX files and some imports are enough.
The Core Setup
- Add the official MDX plugin and let Next treat MD/MDX as pages.
next.config.js
import createMDX from '@next/mdx';
const withMDX = createMDX({
// Add remark/rehype plugins if/when needed
options: {
remarkPlugins: [],
rehypePlugins: [],
},
});
/** @type {import('next').NextConfig} */
const nextConfig = {
...
pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],
};
export default withMDX(nextConfig);
- Optionally customize how MDX renders components (I kept it minimal for now):
mdx-components.tsx
import type { MDXComponents } from 'mdx/types';
export function useMDXComponents(components: MDXComponents = {}): MDXComponents {
return {
...components,
};
}
- Type the metadata you export from MDX so TS understands it when imported elsewhere.
src/types/mdx.d.ts
declare module '*.mdx' {
import type { ComponentType } from 'react';
const MDXComponent: ComponentType<any>;
export default MDXComponent;
export const metadata: {
title?: string;
description?: string;
date?: string;
author?: string;
tags?: string[];
};
}
- Create a post as a route. In the App Router, a folder is your slug and
page.mdx
is the page.
src/app/blog/how-to-export-ig-followers-tutorial/page.mdx
export const metadata = {
title: 'How to Export Instagram Followers (CSV, Excel, JSON)',
description: 'Step-by-step guide…',
date: '2025-08-28',
};
import Image from 'next/image';
- Build a simple index page by importing metadata straight from MDX modules.
src/app/blog/page.tsx
import Link from 'next/link';
import { metadata as igExport } from './how-to-export-ig-followers-tutorial/page.mdx';
const posts = [
{
slug: 'how-to-export-ig-followers-tutorial',
title: igExport?.title ?? 'How to Export Instagram Followers',
description: igExport?.description,
date: igExport?.date,
},
];
export default function BlogIndexPage() {
// Render cards linking to /blog/[slug]
}
- Keep your sitemap honest by importing the same metadata for
lastModified
.
src/app/sitemap.ts
import type { MetadataRoute } from 'next';
import { metadata as igExportPost } from './blog/how-to-export-ig-followers-tutorial/page.mdx';
import { getURL } from '@/utils/get-url';
export default function sitemap(): MetadataRoute.Sitemap {
return [
// …other routes
{
url: getURL('blog/how-to-export-ig-followers-tutorial'),
lastModified: igExportPost?.date ? new Date(igExportPost.date) : new Date(),
changeFrequency: 'weekly',
priority: 0.7,
},
];
}
The Aha Moments (and a few gotchas)
- MDX as modules: You can import both the rendered component and named exports (
metadata
) from any.mdx
file. That made the blog index and sitemap trivial. - Keep it typed: The
*.mdx
module declaration means TS won’t complain when you doimport { metadata } from 'some-post/page.mdx'
. - Less is more: I didn’t reach for Contentlayer because I don’t need filesystem crawling or transformations. With a handful of posts, a tiny array is fine.
Contentlayer vs. Native MDX
What Contentlayer gives you:
- Schemas and types: Define required fields and get generated TypeScript. Build fails if a post is missing a
title
ordate
. - Content graph: Read files from a
content/
directory, compute slugs/paths, and query everything in one place. - Computed fields: Derive
readingTime
,slug
, canonical URLs, etc., at build time. - Good for docs sites: Multiple document types (Guides, API refs, Changelogs) with strict structure.
Native MDX strengths (why I chose it here):
- Zero ceremony: No schema layer, no background watcher — just
.mdx
files and imports. - Co‑location: The post lives at
app/blog/[slug]/page.mdx
, same place users will visit. - Good enough typing: A tiny
*.mdx
module declaration plus optional Zod to validatemetadata
if you want stricter checks.