Create an MDX-powered Blog with Next.js

Let's create an MDX-powered blog and portfolio starter that you can deploy right away with Next.js and Tailwind CSS

·13 min read
Cover Image for Create an MDX-powered Blog with Next.js

In this blog post, I want to build with you an MDX-powered blog with Next.js and Tailwind CSS: at the end of this tutorial, we will have built a free blog template that you can use to deploy your portfolio and your blog today. In addition, we will also be using Typescript and Tailwind CSS.

Getting Started

Bootstrapping the Next.js template with create-next-app

Let's kick-start the project with create-next-app - a Vercel utility to build a minimal Next.js repository. Fire up your terminal and run the following command:

npx create-next-app --ts

The command should prompt you for the project's name; type it out and continue. The command will then install the packages, and if it all goes well, you should see the output below:

Initialized a git repository.

Success! Created mk-next-blog-kit at /Users/MakerKit/mk-next-blog-kit
Inside that directory, you can run several commands:

It should create a folder structure similar to the one below:

Installing dependencies

Run the command below to install other dependencies we need:

npm i --save rehype-slug rehype-highlight rehype-autolink-headings gray-matter feed @mdx-js/mdx next-sitemap date-fns

Adding Tailwind CSS

Tailwind CSS is the hottest CSS utility framework at the time of writing, and it will likely be for a while.

Create a file named postcss.config.js in your root folder:

module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},

And now paste the following configuration to a new file named tailwind.config.js:

const plugin = require('tailwindcss/plugin');
module.exports = {
  content: ['./**/*.tsx'],
  darkMode: 'class',
  corePlugins: {
    container: false,
  theme: {
    container: {
      center: true,
      padding: {
        DEFAULT: '1rem',
        sm: '2rem',
    fontFamily: {
      serif: ['Bitter', 'serif'],
      sans: [
        'SF Pro Text',
        'Segoe UI',
      monospace: [`SF Mono`, `ui-monospace`, `Monaco`, 'Monospace'],
  plugins: [customContainerPlugin, plugin(ellipisfyPlugin)],
function ellipisfyPlugin({ addUtilities }) {
  const styles = {
    '.ellipsify': {
      overflow: 'hidden',
      'text-overflow': 'ellipsis',
      'white-space': 'pre',
function customContainerPlugin({ addComponents }) {
    '.container': {
      '@screen lg': {
        maxWidth: '1024px',
      '@screen xl': {
        maxWidth: '1166px',

Of course, feel free to customize the configuration as you prefer.

The last step to activate Tailwind is to edit your styles/global.css file and prepend the following content to the existing styles:

@tailwind base;
@tailwind components;
@tailwind utilities;
# rest of your file goes here...

Et voilá, we're all set to start using Tailwind CSS across our pages! Now on to building the entities of our blog.

Blog Entities

To start, we create 3 empty directories in the root directory, in which we can place the three main entities of our blog: _posts, _collections, and _authors.

Additionally, we create two more folders, lib in which we place logic and API, and components, which hosts the components of our codebase (except for pages, which we add to the pages folder).


Here we add the authors of our blog; if you're a solo writer, it can be overkill. If you have a team of writers, you can add them to the _authors folder as a JSON file with the following interface:

type Author = {
    name: string;
    picture: string;
    url: string;
export default Author;


Collections are, as the name says, a collection of posts. For example, we included this blog post in the tutorials collection. If you pay attention to the URL of this blog post, you can notice that we structured it with the slug of the collection tutorials and the slug of the blog post.

Collections have the following interface:

interface WithEmoji {
    emoji?: string;
interface WithLogo {
    logo?: string;
interface Collection extends WithEmoji, WithLogo {
    name: string;
    slug: string;
    emoji: string;
export default Collection;

As you can see, we added the possibility to assign an image or an emoji to the collection.


This entity contains the data of our blog post, made up of the content of our MDX files and some relative content retrieved using references.

import Author from './author';
import Collection from './collection';
type BlogPost = {
    author: Author;
    collection: Collection;
    image: string;
    description: string;
    slug: string;
    title: string;
    date: string;
    live: boolean;
    tags: string[];
    readingTime: number;
    content: string;
export default BlogPost;

Adding our first blog post

Now that we are aware of the shape of our entities and where we can place them, we can add our first blog post. Let's start by adding our first Author, Giancarlo!

Let's create a file named giancarlo.json at _authors/giancarlo.json with the following content:

    "name": "Giancarlo",
    "picture": "/assets/images/authors/giancarlo.png",
    "url": ""

Next, we can add a collection; let's name it tutorials just like this post.

Let's create a file named tutorials.json at _collections/tutorials.json with the following content:

    "name": "tutorials",
    "emoji": "🖥️"

Finally, we can create a blog post with the name create-blog-post.mdx at _posts/create-blog-post.mdx. NB: in this case, we're writing an MDX file. Therefore the extension is .mdx.

You can use the content below or anything else you may think of:

title: 'Create an MDX-powered Blog with Next.js'
collection: '_collections/tutorials.json'
author: '_author/giancarlo.json'
date: 2022-03-30
live: true
image: '/assets/images/posts/create-blog-mdx-nextjs.png'
description: "Let's create an MDX-powered blog and portfolio starter that you can deploy right away with Next.js and Tailwind CSS"
## My first blog post

And yay, we added our first blog post!

As you may have noticed, both collection and author are references to paths, which we resolve when building the blog posts.

Why add authors and collections as files?

Good question! Eventually, you will want to use a CMS (such as Forestry, Netlify CMS, Tina, etc.); adding this metadata as files means you can add and update them using the CMS rather than being forced to deploy a new version of your code.

This will make it easier to make edits, especially if the blog post will be maintained by non-technical users.

Blog API

We need to define the functions to read the blog posts, collections, and authors.

We create a Typescript file named api.ts at lib/blog/api.ts in which we define the utilities to create our blog.

At the top of this file, we define a couple of constants we need to use:

const POSTS_DIRECTORY_NAME = '_posts';
const COLLECTIONS_DIRECTORY_NAME = `_collections`;
const AUTHORS_DIRECTORY_NAME = `_authors`;
const CWD = process.cwd();
const postsDirectory = join(CWD, POSTS_DIRECTORY_NAME);
const collectionsDirectory = join(CWD, COLLECTIONS_DIRECTORY_NAME);
const authorsDirectory = join(CWD, AUTHORS_DIRECTORY_NAME);

Next, let's define the function that allows us to retrieve the list of JSON files at the path specified:

function readJson(directoryName: string) {
  return readdirSync(directoryName)
        .map((slug) => {
            const path = join(CWD, directoryName, slug);
            if (existsSync(path)) {
                const json = readFileSync(path, 'utf-8');
                try {
                    const data = JSON.parse(json) as Collection;
                    const realSlug = slug.replace('.json', '');
                    return {
                } catch (e) {
                    console.warn(`Error while reading JSON file`, e);

To read an MDX file, we need a small utility to read the frontmatter of the markdown file. To do so, we can use the dependency gray-matter:

import matter from 'gray-matter';
export function readFrontMatter(fullPath: string) {
  try {
    const fileContents = readFileSync(fullPath, 'utf-8');
    return matter(fileContents);
  } catch (e) {
    console.warn(`Error while reading Front matter at ${fullPath}`, e);

The function below allows us to read a blog post using its slug and return its data and contents.

NB: don't worry about the functions within we haven't defined. We have skipped their implementations for simplicity, but you can find the complete source code at the end of the article.

function getPostBySlug(slug: string) {
  const postFileName = `${slug}.mdx`;
  const postPath = join(postsDirectory, postFileName);
  const file = readFrontMatter(postPath);
  if (!file) {
  const content = file.content;
  const data =;
  const empty = Object.keys(data).length === 0;
  if (empty) {
  const readingTime = getReadingTimeInMinutes(content);
  const post: Partial<BlogPost> = {
  for (const field in data) {
    if (field === 'slug') {
      post[field] = slug;
    if (field === 'collection') {
      post[field] = getCollection(data[field]);
    if (field === 'author') {
      post[field] = getAuthor(data[field]);
    if (field === 'content') {
      post[field] = content;
    if (field === 'date' && {
      try {
        post[field] = new Date(;
      } catch (e) {
        console.error(`Error processing blog post date ${}`);
    if (data[field]) {
      Object.assign(post, {
        [field]: data[field],
  return post as BlogPost;

Let's summarize what happens in the function above:

  • we read the blog post's matter by using its slug
  • we iterate the fields of the MDX file and transform them into a processed object which has the shape BlogPost
  • the collection and the author's data are also collected using the file system and transformed into their relative objects

Time to get our hands dirty with some components.

Add a Next.js Blog Post page

As you may know, Next.js uses file-system routing. As such, we need to define the following path:

    - [slug].tsx

The collection folder represents the collection name, while [slug] is the blog post's slug, which we use to find our blog post. The slug parameter reflects the way we named our MDX file.

Our goals are the following:

  • fetch the correct post using the slug parameter provided
  • fetch a few more posts to display belonging to the same collection
  • compile the MDX content to raw HTML

Let's define a function to compile the raw MDX to HTML. To do so, we use the packages we installed in the very beginning:

import rehypeHighlight from 'rehype-highlight';
import rehypeSlug from 'rehype-slug';
import rehypeAutoLinkHeadings from 'rehype-autolink-headings';
export async function compileMdx(markdown: string) {
  const { compile } = await import('@mdx-js/mdx');
  const code = await compile(markdown, {
    outputFormat: 'function-body',
    rehypePlugins: [rehypeHighlight, rehypeSlug, rehypeAutoLinkHeadings],
  return String(code);

It's time to define the [slug].tsx page, which is our blog post page.

Generating Blog Posts using getStaticPaths

When creating dynamic pages with SSG, we use getStaticPaths, a special Next.js function that allows you to create a list of paths generated as static files.

Let's explain the flow behind generating static paths with getStaticPaths for each of our blog posts:

  • Collecting the posts: initially, we collect all the blog posts from the file-system
  • Adding the URL parameters: for each blog post collected, we need to pass an object containing the dynamic URL parameters of the page. In our case, we need to pass the collection and the post's slugs
export function getStaticPaths() {
  const posts = getAllPosts();
  const paths = => {
    const slug = post.slug;
    const collection =;
    return {
      params: {
  return {
    fallback: false,

We build the array paths with items such as:


These parameters are used to match the URL /[collection]/[slug]; the parameters get passed to the function getStaticProps, another specific Next.js function that takes the parameters collected and returns data fetched at build-time (i.e., when we build and deploy the Next.js application to Vercel).

We use the getStaticProps function to fetch a blog post from the file-system and return it to the page component, which is responsible for rendering the page's data.

Passing data to the page component using getStaticProps

Using the data collected by the getStaticPaths function hook, we can now retrieve the necessary data for rendering our blog posts. We use the API functions we defined above in the post.

Let's summarise the flow:

  • Getting the Post: we use the slug (retrieved from the URL parameter) to fetch the post from the file-system using the function getPostBySlug
  • Getting similar posts: retaining readers on blog is hard: it's very valuable to get your readers' attention by displaying other blog posts they could be interested in; we do so by getting the latest posts with the same collection using the function getPostsByCollection
  • Compiling the MDX: finally, we compile the MDX string to a format that our component MDXRenderer can display
  • Passing data to the page component: the data collected will be passed to the page as component properties
export async function getStaticProps({ params }: Params) {
  const { slug, collection } = params;
  const maxReadMorePosts = 6;
  const post = getPostBySlug(slug);
  if (!post) {
    return {
      notFound: true,
  const morePosts = getPostsByCollection(collection)
    .filter((item) => item.slug !== slug)
    .slice(0, maxReadMorePosts);
  const content = await compileMdx(post.content ?? '');
  return {
    props: {

NB: to build the collections' and tags' pages, we do something quite similar to the above, but instead of fetching the posts, we fetch a unique set of collections and tags listing the articles assigned to them.

For simplicity, you can read the source code linked at the bottom of this article to check how we do it.

Creating the Blog Post's Page Component

Our blog post's page looks like something similar to the below:

  • Header: at the top of the page, we display the page's header
  • Post Head: the head contains information and metadata about the post, such as the Open Graph tags, Rich Results structured data, and other useful SEO meta tags. For more information about this, check out the post about boosting your Next.js application SEO and get more traffic to your blog posts
  • Post: the content of the blog post, such as the title and the MDX content
  • Posts List: the list of the latest posts with the same collection
const PostPage = ({ post, morePosts, content }) => {
  return (
      <Header />
        <PostHead post={post} />
        <Post content={content} post={post} />
        <PostsList posts={morePosts} />
export default PostPage;

For simplicity, we won't be building all the components listed in this post, but you are free to copy and read the code of the Next.js MDX Blog Starter Template, which was the inspiration for this blog post.

Rendering MDX content as HTML

To convert and render the compiled MDX code to HTML, we need to use a runtime function provided by the package @mdx-js/mdx.

Below, you can find the component that renders its relative HTML node, given compiled MDX code.

import * as runtime from 'react/jsx-runtime.js';
import { runSync } from '@mdx-js/mdx';
type MdxComponent = React.ExoticComponent<{
  components: Record<string, React.ReactNode>;
function MDXRenderer({ code }: { code: string }) {
  const { default: MdxModuleComponent } = runSync(code, runtime) as {
    default: MdxComponent;
  return <MdxModuleComponent components={{}} />;
export default MDXRenderer;

Take a good look at the property components of the MdxModuleComponent component: at the moment, it's empty. However, we can pass a list of components that we want to be able to render by default in our MDX files.

For example, we can augment the default img tag with our own. In fact, a common practice is to swap the default img tag with Next's own and optimized Image component. Let's see how it's done:

type ImageLayout = 'fixed' | 'fill' | 'intrinsic' | 'responsive' | undefined;
type StringObject = Record<string, string>;
const NextImage: React.FC<StringObject> = (props: StringObject) => {
  const width = props.width ?? '4';
  const height = props.height ?? '3';
  return (
      layout={(props.layout as ImageLayout) ?? 'responsive'}
const MDXComponents = {
    img: NextImage,
    Image: NextImage,
export default MDXComponents;

After having defined the object MDXComponents, we can edit the MDXRenderer component to include the augmentd components listed above:

return <MdxModuleComponent components={MDXComponents} />;

At this point, all the images will be rendered using Next.js's incredible Image component; furthermore, we can use the component Image directly in our MDX files, which allows us to use all the properties available:

   alt={'Your Image txt'}

The Makerkit's Next.js Blog Starter template includes the following components: Image, ExternalLink, and Video.

Final Result

Below is the final result of what we built, and what you will find in Blog Starter:

Loading video...

Blog Starter Source Code

The final result of this starter template is a fast, responsive, dark-mode ready, and functional blog that you can use as the foundation for yours. In addition, we have already optimized it for Search Engines, so SEO is also taken care of for you.

You can find the complete source code on GitHub. It's free and open-source, which means you can use it, fork it, and edit it.

Subscribe to our Newsletter
Get the latest updates about React, Remix, Next.js, Firebase, Supabase and Tailwind CSS

Read more about Tutorials

Cover Image for Next.js 13: complete guide to Server Components and the App Directory

Next.js 13: complete guide to Server Components and the App Directory

·14 min read
Unlock the full potential of Next.js 13 with our most complete and definitive tutorial on using server components and the app directory.
Cover Image for Pagination with React.js and Supabase

Pagination with React.js and Supabase

·6 min read
Discover the best practices for paginating data using Supabase and React.js using the Supabase Postgres client
Cover Image for How to sell code with Lemon Squeezy and Github

How to sell code with Lemon Squeezy and Github

·7 min read
Sell and monetize your code by giving private access to your Github repositories using Lemon Squeezy
Cover Image for Writing clean React

Writing clean React

·9 min read
Level up your React coding skills with Typescript using our comprehensive guide on writing clean code. Start writing clean React code, today.
Cover Image for How to use MeiliSearch with React

How to use MeiliSearch with React

·12 min read
Learn how to use MeiliSearch in your React application with this guide. We will use Meiliseach to add a search engine for our blog posts
Cover Image for Setting environment variables in Remix

Setting environment variables in Remix

·3 min read
Learn how to set environment variables in Remix and how to ensure that they are available in the client-side code.