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', 'Inter', 'system-ui', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Ubuntu', ], 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', }, }; addUtilities(styles); } function customContainerPlugin({ addComponents }) { 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 Yay!

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 { data, slug, realSlug }; } catch (e) { console.warn(`Error while reading JSON file`, e); } } } }) .filter(Boolean); }

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) { return; } const content = file.content; const data =; const empty = Object.keys(data).length === 0; if (empty) { return; } const readingTime = getReadingTimeInMinutes(content); const post: Partial<BlogPost> = { live:, readingTime, }; for (const field in data) { if (field === 'slug') { post[field] = slug; continue; } if (field === 'collection') { post[field] = getCollection(data[field]); continue; } if (field === 'author') { post[field] = getAuthor(data[field]); continue; } if (field === 'content') { post[field] = content; continue; } if (field === 'date' && { try { post[field] = new Date(; continue; } 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:

[collection] - [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: { collection, slug, }, }; }); return { paths, fallback: false, }; }

We build the array paths with items such as:

{ collection, slug, }

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: { post, content, morePosts, }, }; }

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 /> <Layout> <PostHead post={post} /> <Post content={content} post={post} /> <PostsList posts={morePosts} /> </Layout> </> ); }; 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 ( <Image width={width} height={height} layout={(props.layout as ImageLayout) ?? 'responsive'} className={props.class} src={props.src} alt={props.alt} {...props} /> ); }; 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:

<Image width={2012} height={680} alt={'Your Image txt'} src={'/assets/images/posts/image.png'} />

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.

Read more about Tutorials

Cover Image for Building an AI Writer SaaS with Next.js and Supabase

Building an AI Writer SaaS with Next.js and Supabase

·57 min read
Learn how to build an AI Writer SaaS with Next.js and Supabase - from writing SEO optimized blog posts to managing subscriptions and billing.
Cover Image for Announcing the Data Loader SDK for Supabase

Announcing the Data Loader SDK for Supabase

·8 min read
We're excited to announce the Data Loader SDK for Supabase. It's a declarative, type-safe set of utilities to load data into your Supabase database that you can use in your Next.js or Remix apps.
Cover Image for Adding AI capabilities to your Next.js SaaS with Supabase and HuggingFace

Adding AI capabilities to your Next.js SaaS with Supabase and HuggingFace

·20 min read
In this tutorial, we will learn how to use add AI capabilities to your SaaS using Supabase Vector, HuggingFace models and Next.js Server Components.
Cover Image for Building an AI-powered Blog with Next.js and WordPress

Building an AI-powered Blog with Next.js and WordPress

·17 min read
Learn how to build a blog with Next.js 13 and WordPress and how to leverage AI to generate content.
Cover Image for Using Supabase Vault to store secrets

Using Supabase Vault to store secrets

·6 min read
Supabase Vault is a Postgres extension that allows you to store secrets in your database. This is a great way to store API keys, tokens, and other sensitive information. In this tutorial, we'll use Supabase Vault to store our API keys
Cover Image for Introduction to Next.js Server Actions

Introduction to Next.js Server Actions

·9 min read
Next.js Server Actions are a new feature introduced in Next.js 13 that allows you to run server code without having to create an API endpoint. In this article, we'll learn how to use them.