ionicons-v5-a Back
How to Build a Static MDX Blog with Next.js and Contentlayer
nextjs

23 min read

How to Build a Static MDX Blog with Next.js and Contentlayer

Discover a comprehensive step-by-step tutorial for constructing a static files blog with MDX, Next.js, and Contentlayer. This guide comes with detailed code examples to assist you along the way!


Step 1: Setup Next.js 13 with static export

npx create-next-app@latest

After installation let’s enable static export in next.config.js:

//next.config.js
 
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'export',
};
 
module.exports = nextConfig;

Next.js, with its static export capability, allows you to deploy and host the website on any web server capable of serving HTML/CSS/JS static assets.

Step 2: Contentlayer

Installing Contentlayer

npm install contentlayer next-contentlayer date-fns

Next.js Configuration

//next.config.js
 
const { withContentlayer } = require('next-contentlayer');
 
/**
 * @type {import('next').NextConfig}
 **/
const nextConfig = {
  output: 'export',
};
 
module.exports = withContentlayer(nextConfig);

TypeScript Configuration

// tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "contentlayer/generated": ["./.contentlayer/generated"]
      // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".contentlayer/generated"
    // ^^^^^^^^^^^^^^^^^^^^^^
  ]
}

Content Schema and rehype

Given that our project involves creating a basic blog site, we will introduce a single document type called Post. Before creating the contentlayer config let’s install additional dependencies that we will need to configure our publication:

npm install rehype-autolink-headings rehype-pretty-code rehype-slug hastscript

and then we are ready to create contentlayer.config.ts:

import { defineDocumentType, makeSource } from 'contentlayer/source-files';
 
import rehypeAutolinkHeadings, {
  type Options as AutolinkOptions,
} from 'rehype-autolink-headings';
import rehypePrettyCode, {
  type Options as PrettyCodeOptions,
} from 'rehype-pretty-code';
import rehypeSlug from 'rehype-slug';
import { s } from 'hastscript';
 
// defining document type where we will defing our mdx document frontmatter structure
// (all these fields will be passed to static json with types that can be imported and used by next app)
export const Post = defineDocumentType(() => ({
  name: 'Post',
  filePathPattern: `**/*.mdx`,
  contentType: 'mdx',
  fields: {
    title: { type: 'string', required: true },
    date: { type: 'date', required: true },
    excerpt: {
      type: 'string',
      required: true,
    },
    tags: {
      type: 'list',
      of: { type: 'string' },
      required: true,
    },
    cover: {
      type: 'image',
      required: true,
    },
  },
  computedFields: {
    url: {
      type: 'string',
      resolve: (post) => `/blog/${post._raw.flattenedPath}`,
    },
    readTime: {
      type: 'string',
      resolve: (post) => {
        const wordsPerMinute = 200;
        const noOfWords = post.body.raw.split(/\s/g).length;
        const minutes = noOfWords / wordsPerMinute;
        const readTime = Math.ceil(minutes);
        return readTime;
      },
    },
  },
}));
 
export default makeSource({
  // folder in which we will store our content mdx files
  contentDirPath: 'posts',
  documentTypes: [Post],
  mdx: {
    rehypePlugins: [
      /**
       * Adds ids to headings
       */
      rehypeSlug,
      [
        /**
         * Adds auto-linking button after h1, h2, h3 headings
         */
        rehypeAutolinkHeadings,
        {
          behavior: 'append',
          test: ['h2', 'h3'],
          properties: { class: 'heading-link' },
          content: s(
            'svg',
            {
              xmlns: 'http://www.w3.org/2000/svg',
              viewBox: '0 0 24 24',
              width: '24',
              height: '24',
              fill: 'none',
              stroke: 'currentColor',
              'stroke-width': '2',
              'stroke-linecap': 'round',
              'stroke-linejoin': 'round',
              'aria-label': 'Anchor link',
            },
            [
              s('line', { x1: '4', y1: '9', x2: '20', y2: '9' }),
              s('line', { x1: '4', y1: '15', x2: '20', y2: '15' }),
              s('line', { x1: '10', y1: '3', x2: '8', y2: '21' }),
              s('line', { x1: '16', y1: '3', x2: '14', y2: '21' }),
            ],
          ),
        } satisfies Partial<AutolinkOptions>,
      ],
      [
        /**
         * Enhances code blocks with syntax highlighting, line numbers,
         * titles, and allows highlighting specific lines and words
         */
        rehypePrettyCode,
        {
          theme: {
            // light: 'github-light',
            dark: 'github-dark',
          },
        } satisfies Partial<PrettyCodeOptions>,
      ],
    ],
  },
});

Test MDX file

Let’s create our first mdx file (by config above we will store it in posts folder):

// posts/my-first-post.mdx (keep in mind that the name of this file will be our slug in the future)
 
title: My first post
date: 2023-07-20
excerpt: This is my first post
tags: [next.js, blog, static blog, tailwind, mdx, contentlayer]
cover: ./mdnext.png
 
---
 
Hello my first post!

Although we have a significant amount of configurations to set up before running any code, let’s be patient and proceed to create our first page. By doing so, we can eventually observe the results in the browser.

Step 3: Routing and page layout

If you are not familiar with Next.js routing, I highly recommend reading about the routing fundamentals. Our main page will render the list of our blog posts, so let’s open app/page.tsx and replace all the deafault content with:

// you may notice Typescript error here but that's fine, the needed types will be generated after we run dev server
import { allPosts } from 'contentlayer/generated';
import { compareDesc } from 'date-fns';
 
export default async function Blog() {
  const sortedPosts = allPosts.sort((a, b) =>
    compareDesc(new Date(a.date), new Date(b.date)),
  );
 
  return (
    <>
      <h1>Blog posts</h1>
      <div>
        {sortedPosts.map((post) => (
          <div key={post._id}>{post.name}</div>
        ))}
      </div>
    </>
  );
}

Contantlayer and Next.js troubleshooting

At the time of writing this article the latest versions of Next.js and Contentlayer has dependency conflicts. The issues described here.

Just add this to package.json and run npm i:

"overrides": {
    "@opentelemetry/api": "1.4.1",
    "@opentelemetry/core": "1.13.0",
    "@opentelemetry/exporter-trace-otlp-grpc": "0.39.1",
    "@opentelemetry/resources": "1.13.0",
    "@opentelemetry/sdk-trace-base": "1.13.0",
    "@opentelemetry/sdk-trace-node": "1.13.0",
    "@opentelemetry/semantic-conventions": "1.13.0"
}

Now it’s time to run our publication

npm run dev

Check the logs, follow contentlayer errors (at least you will need to add a cover to your post) and you should see the resulting list. Make sure to re-run your server after fixes, sometimes contentlayer needs this.

Step 4: Content page with MDX

Let’s create a routing that satisfies {host}/blog/{slug}. In Next it will be a app/blog/[slug]/page.tsx.

// app/blog/[slug]/page.tsx
import { allPosts } from 'contentlayer/generated';
import { format, parseISO } from 'date-fns';
import { notFound } from 'next/navigation';
import Image from 'next/image';
import MDXContent from '@/components/mdx-content';
 
interface IProps {
  params: { slug: string };
}
 
export const generateStaticParams = async () =>
  allPosts.map((post) => ({ slug: post._raw.flattenedPath }));
 
export default async function Page({ params: { slug } }: IProps) {
  const post = allPosts.find((post) => post._raw.flattenedPath === slug);
 
  if (!post) notFound();
 
  return (
    <>
      <div>
        <article>
          <div>
            <time
              dateTime={post?.date}
              className="uppercase font-bold text-blue-600 text-xs"
            >
              {format(parseISO(post?.date), 'MMM dd, yyyy')}
            </time>
            <p>{post.readTime} min read</p>
          </div>
          <h1>{post.title}</h1>
          <p>{post.excerpt}</p>
          <div>
            <Image
              src={post.cover.filePath.replace('../public', '')}
              width={700}
              height={350}
              priority={true}
              alt={post.title}
            />
          </div>
          <MDXContent code={post.body.code} />
        </article>
      </div>
    </>
  );
}

Done, now let’s define the MDX components that will be used for content transformation MDX -> HTML.

// components/mdx-content.tsx
// here we will define the rules how our mdx content syntax will be converted to HTML
// otherwise imported components can be used just inside MDX
 
import { useMDXComponent } from 'next-contentlayer/hooks';
import { FC } from 'react';
import { MDXComponents } from './mdx-components';
 
interface IProps {
  code: string;
}
 
const MDXContent: FC<IProps> = ({ code }) => {
  const Component = useMDXComponent(code);
  return <Component components={MDXComponents} />;
};
 
export default MDXContent;
 
// components/mdx-components.tsx
/**
 * Image component that uses figure tag with optional title
 */
const img = ({ src, alt, title }: React.HTMLProps<HTMLImageElement>) => {
  return (
    <figure className="flex h-fit w-fit flex-col kg-card" aria-label={alt}>
      <img src={src || ''} alt={alt} />
      {title && <figcaption className="text-center">{title}</figcaption>}
    </figure>
  );
};
 
/**
 * Replace the p elements with div elements, as p elements have restrictions on
 * the types of elements that can be nested inside them.
 */
const p = (props: React.HTMLProps<HTMLParagraphElement>) => {
  return <div className="my-6" {...props} />;
};
 
export const MDXComponents = { img, p };

Before checking your result you can encounter an error:

Error: Image Optimization using the default loader is not compatible with { output: 'export' }

Let’s disable Next image optimization so far, we will get back to it later or you can read what to do with this here.

// next.config.js
const { withContentlayer } = require('next-contentlayer');
 
/**
 * @type {import('next').NextConfig}
 **/
const nextConfig = {
  output: 'export',
  images: { unoptimized: true },
  //^^^^^^^^^^^^^^^^^^^^^^^^^^^^
};
 
module.exports = withContentlayer(nextConfig);

Done, now you should be able to see your content in the browser.

Styling

Of course it’s TailwindCSS. Also you should consider using @tailwindcss/typography for your content part. More info here.

Step 5: Metadata

In Next.js, you can utilize the Metadata API to define your application metadata, such as meta and link tags within the HTML head element, to enhance SEO and web shareability.

Let’s define the metadata for our content page:

// add this to app/blog/[slug]/page.tsx
import { Metadata } from 'next';
 
export function generateMetadata({ params: { slug } }: IProps): Metadata {
  const post = allPosts.find((post) => post._raw.flattenedPath === slug);
 
  if (!post) {
    return {};
  }
 
  const { excerpt, title, date } = post;
 
  const description = excerpt;
 
  const ogImage = {
    url: `${process.env.HOST}/blog/${slug}/og.png`,
  };
 
  return {
    title,
    description,
    openGraph: {
      type: 'article',
      url: `${process.env.HOST}/blog/${slug}`,
      title,
      description,
      publishedTime: date,
      images: [ogImage],
    },
    twitter: {
      title,
      description,
      images: ogImage,
      card: 'summary_large_image',
    },
  };
}

That’s it now Next.js will be generating metadata for our content page.

opengraph-image and twitter-image

In the code above you can see that we are adding ${process.env.HOST}/blog/${slug}/og.png as a Metadata images. This is supposed to be a generated ones in our case so we don’t need to create a new image each time we create a post.

Here is a complete example of generating images for static export in Next.js.

Structured data

Structured data refers to a standardized format used to organize and provide context to information on a web page. It helps search engines and other applications understand the content and context of the data more effectively. This data is typically marked up using specific formats like JSON-LD, RDFa, or microdata, allowing search engines to extract and display relevant information directly in search results or other applications.

Structured data can include various types of information, such as product details, event information, reviews, recipes, FAQs, and more. By implementing structured data, website owners can improve their chances of appearing in rich snippets or special search result features, enhancing their visibility and providing users with more valuable and informative search results.

npm install schema-dts

schem-dts - provides TypeScript definitions for Schema.org vocabulary in JSON-LD format.

// add this to app/blog/[slug]/page.tsx
import { Article, Graph, WithContext } from 'schema-dts';
 
export default async function Page({ params: { slug } }: IProps) {
  // ...
  const structuredData: WithContext<Article> = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    url: `${process.env.HOST}/blog/${slug}/`,
    image: {
      '@type': 'ImageObject',
      url: `${process.env.HOST}${post.cover.filePath.replace(
        '../public',
        '',
      )}/`,
    },
    description: post.excerpt,
    datePublished: post.date,
    publisher: {
      '@type': 'Person',
      name: 'Dzmitry Kozhukh',
      url: process.env.HOST,
      image: '/avatar.png',
    },
    author: {
      '@type': 'Person',
      name: 'Dzmitry Kozhukh',
      url: process.env.HOST,
      image: '/avatar.png',
    },
  };
  const jsonLd: Graph = {
    '@context': 'https://schema.org',
    '@graph': [structuredData],
  };
 
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      // ...
    </>
  );
}

You can check the result in DevTools:

<script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@graph": [
      {
        "@context": "https://schema.org",
        "@type": "Article",
        "headline": "My first post",
        "url": "undefined/blog/my-first-post/",
        "image": { "@type": "ImageObject", "url": "undefined/mdnext.png/" },
        "description": "This is my first post",
        "datePublished": "2023-07-20T00:00:00.000Z",
        "publisher": {
          "@type": "Person",
          "name": "Dzmitry Kozhukh",
          "image": "/avatar.png"
        },
        "author": {
          "@type": "Person",
          "name": "Dzmitry Kozhukh",
          "image": "/avatar.png"
        }
      }
    ]
  }
</script>

Step 6: Accelerated mobile pages (AMP)

I described building AMP page with Next.js 13 here.

Moreover, I didn’t find any Next ways to add <link rel="amphtml" href="https://www.example.com/url/to/amp/document.html"> to the blog post content so Google knows that AMP version exists.

And end up with this robust solution:

// scripts/insert-amp-meta.mjs
 
import { globby } from 'globby';
import fs from 'fs';
 
async function insertAmpMetaTag() {
  const pages = await globby(['out/blog/*.html']);
 
  try {
    pages.forEach((pagePath) => {
      const htmlStr = fs.readFileSync(pagePath, 'utf8');
 
      const updatedHtmlStr = htmlStr.replace(
        '<meta charSet="utf-8"/>',
        `<meta charset="utf-8"/><link rel="amphtml" href="https://kozhuhds.com/${pagePath
          .replace('out/', '')
          .replace('.html', '')}/amp/"/>`,
      );
 
      fs.writeFileSync(pagePath, updatedHtmlStr, 'utf8', function (err) {
        if (err) return console.log(err);
      });
    });
  } catch (error) {
    console.log(error);
  }
}
 
insertAmpMetaTag();

with adding this to the postbuild phase:

  "scripts": {
    //...
    "postbuild": "node ./scripts/insert-amp-meta.mjs",
  },

Step 7: Speed optimization

I managed to achieve a pretty good results in the Google PageSpeed. All the tips can be found in this article.

Step 8: robots.txt

robots.txt is a standard text file used by websites to communicate with web crawlers or robots, such as search engine bots. The purpose of this file is to provide instructions to these automated agents on which parts of the website should be crawled or indexed and which parts should be ignored.

Next.js convention is to place robot.txt file inside the app folder.

User-Agent: *
Allow: /
Disallow: /private/
Sitemap: https://kozhuhds.com/sitemap.xml

Step 9: sitemap.xml

A sitemap.xml serves as a roadmap for search engines, guiding them to important pages on the website and improving the site’s visibility in search engine results.

By Next.js convention we should create sitemap.ts inside the app folder. Let’s add our pages to the sitemap:

import { allPosts } from 'contentlayer/generated';
import { MetadataRoute } from 'next';
 
const postsSitemap: MetadataRoute.Sitemap = allPosts.map((post) => ({
  url: `${process.env.HOST}/blog/${post._raw.flattenedPath}`,
  lastModified: post.date,
}));
 
export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: process.env.HOST,
      lastModified: new Date(),
    },
    ...postsSitemap,
  ];
}

Step 10: Custom 404 page

Custom 404 page for static export in Next.js is a bit buggy now, but generally works. My expectations were that Next will treat it as usual page and allow to add metadata and use base layout, but now it’s just a separate page.

// app/not-found.tsx
 
import PostListItem from 'components/post-list-item';
import { allPosts } from 'contentlayer/generated';
import { compareDesc } from 'date-fns';
import { DM_Sans } from 'next/font/google';
 
const dmSans = DM_Sans({
  weight: ['400', '500', '700'],
  display: 'swap',
  subsets: ['latin'],
});
 
export default function NotFound() {
  const sortedPosts = allPosts.sort((a, b) =>
    compareDesc(new Date(a.date), new Date(b.date)),
  );
 
  return (
    <html lang="en" className={dmSans.className}>
      <head>
        <title>Not Found</title>
      </head>
      <body className="antialiased bg-main">
        <div className="max-w-[1440px] mx-auto">
          <div className="space-y-12">
            {sortedPosts.slice(0, 3).map((post) => (
              <PostListItem post={post} key={post._id} />
            ))}
          </div>
        </div>
      </body>
    </html>
  );
}

Step 11: Building and deploy

npm run build

Boom! All your publication files can be found in out folder.

For hosting your static blog I recommend using Vercel with default settings preset for Next.js. Otherwise, you may experience issues with routing.

Vercel build settings example
Vercel build settings example

Conclusion

This article comprehensively addresses all aspects of publication, ensuring ease of writing and maintenance, while also optimizing for search engine discoverability, speed, and leveraging the full potential of Next.js to automate essential SEO tasks.


Dzmitry Kozhukh

Written by Dzmitry Kozhukh

Frontend developer


Next