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

Written by maxm321 | Published 2025/09/24
Tech Story Tags: next.js | blogging | mdx | contentlayer | no-contentlayer | heavy-weighted-cms-systems | light-weight-cms-system | mdx-app-router

TLDRNext.js’s official MDX integration lets you import .mdx as components and export metadata alongside content. 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.via the TL;DR App

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.
  • 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.


Written by maxm321 | Ok computer.
Published by HackerNoon on 2025/09/24