How I Built a Simple MDX Blog in Next.js and why I chose native mdx over Contentlayer

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 export metadata 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

  1. 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);

  1. 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,
  };
}

  1. 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[];
  };
}

  1. 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';

  1. 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]
}

  1. 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 do import { 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 or date.
  • 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 validate metadata if you want stricter checks.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.