Trendy Coder Logo
  • Home
  • About
  • Blog
  • Newsletter

Getting Started with MDX on Next.js

Posted on: May 24 2024
By Dave Becker
Hero image for Getting Started with MDX on Next.js

If you've been developing React applications for some time now, you've probably used some Markdown to document some source code since just about every git repo supports it. Since the arrival of MDX, I began to wonder if it could be used for more than just basic help documentation.

I found that MDX goes way beyond just simple Markup and is an excellent way to enhance all types of web content. In this article, In this article you'll find everything you need to get started with MDX, including:

  • Setting up MDX on Next.js
  • Local MDX
  • Remote MDX
  • MDX Layout tempates
  • Creating MDX components
  • Customizing Themes

What is MDX?

MDX is an extension of basic Markdown syntax we know and love and let's you add React JSX components within Markdown content. So if you're using React, this opens up a whole new realm of possibilities for interactive Markdown content.

Why is MDX important?

MDX can now be used for more than just simple README files, in fact, it can now be used in areas like marketing, sales, blogs and other forms of commercial presentation.

  • SEO Optimization: MDX can be SEO optimized and statically generated for optimal performance.
  • Theming and Styles: MDX can be themed and styled in endless ways, transforming simple Markdown syntax into elegant presentations.
  • No CSS Hassle: No need to deal with tedious CSS and styles to make your HTML pages look great, focus should be on content.
  • Easy To Read: MDX Mardown is easy to read. No need to sift through tons of HTML tags to edit page content.
  • Front Matter: A persistent YAML based meta data section, which allows for flexible content meta definitions without the need of a database or external system.
  • Showcase: Better documentation for showcase applications like Storybook that fully support MDX in v7.

Types of MDX on Next.js

Next.js supports two types of MDX for different development strategies. Both can be used for different cases so it's not just one over the other.

MDX Local:

Parses and renders MDX Markdown using the app router.

MDX Remote:

Loads and parses content from virtually any location


Get Started with MDX on Next.js

If you don't already have a Next.js application set up, you can easily create one to follow along with these examples.

npx create-next-app@latest

The process will give you some options. I'm using TailwindCSS default setup but choose what you prefer since this is not a TailwindCSS specific article.

Install the follow pacakges that I'll be using for this article.

Install the following MDX packages.

npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx next-mdx-remote remark-frontmatter remark-mdx-frontmatter

MDX Tips and Best Practices

A couple key points that I've learned that can really improve and ensure your success with MDX include:

  • Use a common library directory for all of your MDX utils and customizations.
  • Try to keep all of the common and configuration code as static and non-async as possible for better bundling and code splitting.
  • Keep a common content location under your project root, for example /content and then have sub-directories for more specific content, like /content/blog or /content/sports.

I will also use the following path alias conventions using Typescript defined in the tsconfig.json file.

/tsconfig.json
{
  "compilerOptions": {
    ...
    "paths": {
        "@/*": [
          "./*"
        ]
    }
    ...
  }
}


A Simple MDX Example

For this demo, I'll create a simple reusable React JSX component that I'll use throughout this article and import it into Markdown content.

To make things more interesting, I'll create a simple Hero component that can be used as a document or blog header.

Hero Example:

MDX JSX Hero Image

Having the ability to add custom React JSX components to a regular Markdown page definitely opens up some possibilities.


MDX Local Setup

The best way to describe the local setup is that it provides the ability to do the following:

  • Render an MDX page using the app router using page.mdx, similar to page.tsx.
  • Allows import and export ESM notation to render MDX.
  • Can be setup to support Front Matter but needs to use plugins to parse meta data.
  • Supports templates and layout capabilities.

Step 1

Start by modifying the next.config.mjs file and configure some of the plugins we installed earlier.

Local setup requires the use of @next/mdx to add support for .mdx page extensions.

Since the local setup doesn't automatically parse Front Matter meta data, I'll add a couple plugins to provide Front Matter support.

Front Matter is a YAML based meta data added to the top of an MDX page to define simple or complex data objects.


Here's what the next.config.mjs file should look like after the changes.


/next.config.mjs
import withMDX from '@next/mdx';
import remarkFrontmatter from 'remark-frontmatter';
import remarkMdxFrontmatter from 'remark-mdx-frontmatter';

const remarkPlugins = [
  remarkFrontmatter,
  remarkMdxFrontmatter
];

// optional settings. If you don't have any just call nextMDX() as empty
const withMDX = nextMDX({
  extension: /\.mdx?$/,
  options: {
    scope: {
      // global scoped variables that will be accessible in MDX content
    }
    remarkPlugins: [
      ...remarkPlugins
    ],
    rehypePlugins: [
      // Add any ESM rehype plugin here.
    ]
  },
});

const nextConfig = {
  // Configure `pageExtensions` to include MDX files
  pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'],

  // Optionally, add any other Next.js config below

}

export default withMDX(nextConfig)


Available Plugins

Here's some of other available plugins to enhance Markdown content.

  • remarkPlugins - Any ESM module type plugins from Remark. Remark Plugins
  • rehypePlugins - Any ESM module type plugins from Rehype. Rehype Plugins

Step 2

Let's create the Hero component. This React component will accept two props:

  • background
  • heroText

The background can be an image or defaults to a CSS gradient.

The heroText will just be an overlay on top of the background.

I'll add this component to the /components/mdx directory.


/components/mdx/hero.tsx
import React, { type CSSProperties } from 'react';

type HeroProps = {
  background: {
    image?: string;
    type: 'gradient' | 'image';
  };
  heroText: string;
};

const Hero = (props: HeroProps) => {
  const {
    background: { image = '', type = 'gradient' },
    heroText = 'Hero Text Here',
  } = props;

  const backgroundStyles: CSSProperties =
    type === 'image'
      ? {
          backgroundImage: `url(${image})`,
          backgroundSize: 'cover',
          aspectRatio: '4/2',
        }
      : {
          height: 400,
          background:
            'linear-gradient(0deg, rgba(112,34,195,1) 0%, rgba(253,187,45,1) 100%)',
        };

  const styles: Record<string, CSSProperties> = {
    section: {
      display: 'flex',
      justifyContent: 'center',
      alignItems: 'center',
      padding: 50,
      ...backgroundStyles,
    },
    heroText: {
      color: '#ffffff',
      fontSize: '4.0rem',
      fontWeight: 700,
    },
  };
  return (
    <section style={styles.section}>
      <div style={styles.heroText}>{heroText}</div>
    </section>
  );
};

export default Hero;


Step 3

This step is essential for local setup and without it your pages will not render JSX components properly.

You'll need to create a file named mdx-components.tsx in the root of the project and add the following function called useMDXComponents.


/mdx-components.tsx
import type { MDXComponents } from 'mdx/types'
 
export function useMDXComponents(components: MDXComponents): MDXComponents {
  return {
    ...components,
  }
}

This function returns an object with any custom components you'll want to add to your MDX pages. I'll add the Hero component we defined earlier here.

The other benefits of this function include:

  • Override standard HTML Markdown elements like h1, h2, p, etc.
  • Components will be globally available to all MDX content using local strategy.
  • No need to use import to include components, they will already be in the mdx scope.
  • You can add an optional wrapper function to wrap around all your page content.

Here's an example of how to add components.


/mdx-components.tsx
import React from 'react';
import Hero from '@/components/mdx/hero';

export function useMDXComponents(components: MDXComponents): MDXComponents {
  return {
    ...components,
    h1: ({ children, ...props }: React.ComponentPropsWithoutRef<'h1'>) => {
    return (
      <h1 {...props} className="my-8">
        {children}
      </h1>
     );
    },
    h2: ({ children, ...props }: React.ComponentPropsWithoutRef<'h2'>)  => {
    return (
      <h2 {...props} className="my-6">
        {children}
      </h2>
     );
    },
    // Adding the Hero component here so it's available to mdx content
    Hero,
    wrapper: ({children}) => {
      // For example, add the TailwindCSS 'prose' class for styling.
      return <div className="prose">{children}</div>
    }
  }
}

I'm just adjusting the margin styles here on some of the HTML header tags.

If you're using CSS frameworkss like TailwindCSS or other libraries you can apply those classes or styles as needed to produce any theme look and feel.

As you can see, I've added the prose class from the @tailwindcss/typography plugin to demonstrate how the wrapper can be used.

My preference is to use CSS classes for theming but the option is there to adjust the styles however needed.


Step 4

Create an MDX file under the app router directory with the .mdx extension.

/app/demo/page.mdx

# MDX Demo Page

<Hero background={{ image: '/img/blog/assets/mdx-demo-backdrop.jpg', type: 'image'}} heroText="Home Decor"/>

Note that there's no need to import the Hero component because it's already available.


So the simple Hero demo should looks as follows so far.


 your-project
  ├── app
  │   └── demo
  │       └── page.mdx
  └── components
      └── mdx
          └── hero.tsx


Running the Local MDX Page

If you run the following code in Next.js dev server using npm run dev you'll see the following at localhost:3000/demo.


Output:

MDX JSX Hero Image

One clear take away is that we can now render MDX pages the same way as page.tsx pages that are under the app router in Next.js.

This is a huge advantage because it allows us to write full MDX pages that can be statically generated for faster performance and easy to maintain.


MDX Dynamic Routes

If the MDX page is a dynamic app router path and needs access to the params and searchParams on the route, they are available through the props object.

Here's a couple of variations on how to use page.mdx to access router information from the props, since you might need to conditionally create content at times.

/app/demo/alt/[id]/page.mdx

export function ParamsDemo1() {
    return <div>ParamsDemo1</div>
} 
export function ParamsDemo2() {
    return <div>ParamsDemo2</div>
} 

{props.params.id === '123' ? <ParamsDemo1 /> : <ParamsDemo2 />}


/app/demo/alt/page.mdx?id=123

export function SearchParamsDemo1() {
    return <div>SearchParamsDemo1</div>
} 
export function SearchParamsDemo2() {
    return <div>SearchParamsDemo2</div>
} 

{props.searchParams.id === '123' ? <SearchParamsDemo1 /> : <SearchParamsDemo2 />}



Importing MDX Pages

Whether you use a page.tsx or a page.mdx is up to you but when importing MDX page content there's a couple of benefits.

Any import of .mdx extensions using local MDX, will return a JSX element which accepts the components prop.

For example, I'll use a page.tsx to illustrate how to import and embed MDX content.


/app/demo/account/page.tsx
import Standard from '@/content/some/path/standard.mdx';
import Premium from '@/content/some/path/premium.mdx';
import PremiumHero from '@/components/mdx/premium'

function Page({
  params: { accountType },
}: {
  params: { accountType: string | string[] | undefined };
}) {
  return (
    <>
      {accountType === 'premium' ? (
        <Premium
          components={{
            PremiumHero
            Product() {
              return <span style={{ color: 'gold' }}>Premium Services</span>;
            },
            // wrapper/layout key to wrap all of Content2's content
            wrapper({components, ...rest}) {
              return <section style={{backgroundImage: '/premium.jpg'}} {...rest} />
            },
          }}
        />
      ) : (
        <Standard />
      )}
    </>
  );
}

export default Page;

These props are very useful for altering the HTML and styling output at a nested layer.


Local MDX Front Matter

If you're using Front Matter, all of the Front Matter meta data for page.mdx will be available in the frontmatter object, since we configured the plugins remarkFrontmatter and remarkMdxFrontmatter earlier in next.config.mjs.


---
title: 'MDX with Front Matter'
tags: 
 - blue
 - grey 
---

Here's the title {frontmatter.title}

The tag first tag is {frontmatter.tags[0]}


By default, any MDX content .mdx page that exports a metadata named export can provide access to metadata.

For example:


export const metadata = {
  title: 'Premium',
  slug: 'premium-content',
  img: '/some/premium/image.png'
}

## Some MDX content


Then just import the named export metadata.

import Standard, {metadata as standardMeta} from '@/content/some/path/standard.mdx';
import Premium, {metadata as premiumdMeta}from '@/content/some/path/premium.mdx';


function Page({params: {type}}: { params: { type: string}}) {
  return (
    <>
      {type === 'premium' ? (
        <div style={{backgroundImage: premiumMeta.img}}>
          <h1>{premiumMeta.title}</h1>
          <Premium/>
        </div>
      ) : (
        <div style={{backgroundImage: standardMeta.img}}>
          <h1>{standardMeta.title}</h1>
          <Standard />
        </div>
      )}
    </>
  );
}

export default Page;

I personally prefer the YAML Front Matter approach at the page level but it's good to have both options for various needs your project might require.

For example, there might be cases where other teams, departments or content authors might maintain this MDX metadata.


Local MDX Layouts

Aside from the designated wrapper key in the components object, with local MDX you can also define layouts.

Consider the following DocumentLayout that we might want to use to wrap a document MDX content page in.

/components/mdx/layouts/document.tsx

import { PropsWithChildren } from 'react';

export type Document = {
  title?: string;
}

export default function DocumentLayout({
  children,
  title
}: PropsWithChildren & Document) {

  return (
    <article className="prose">
      <h1 className="title">{title}</h1>
      <div className="content">{children}</div>
    </article>
  );
}

Now all we have to do is import the DocumentLayout and export it as the default export and MDX will automatically use it as a layout wrapper.

/app/demo/page.mdx
---
title: 'Layouts and Templates'
---

import DocumentLayout from '@/components/mdx/layouts/document'

export default ({children}) => <DocumentLayout {...frontmatter}>{children}</DocumentLayout>

All of the Markdown content will be wrapped by the layout and passed in as the children.  


The DocumentLayout could also be defined in the useMDXComponents functions we defined earlier but just to show some versatility I imported it directly into the MDX page.

Notice how I'm also using the spread operator to pass the frontmatter object into the layout. This is not possible with the wrapper technique because the frontmatter object is not available.

This is one of the benefits to using local MDX.


Remote MDX

So far, all of the MDX content has either been locally added inline or loaded using the import statement, hence the term local. Currently there isn't a way to load content any other way using local MDX.

Now that we've got a pretty good idea of using local MDX, let's see how it compares with the remote strategy.

Remote MDX is a very dynamic approach over the local approach and provides a solution for loading MDX content from other places where it might be located.


What is Remote MDX

It's important to note, remote MDX is not capable of using the import or export statements to load content or even export layouts.

The remote MDX approach is a more programatic way of loading and rendering remote content.

Here are some examples of where content might be located:

  • Might be stored and read from a remote database or CMS.
  • Could be dynamically created on the fly from a JSON response from an API endpoint.
  • Could be read from the underlying file system.
  • It could be all of the above

To load content from these sources, we'll use the next-mdx-remote package that was installed earlier.


Remote MDX Setup

The next-mdx-remote package provides some helpers to compile MDX content dynamically.

Since this package originated before Next.js 13 moved to using React Server Components (RSC), the serialize and the <MDXRemote> helpers were primarily used to do the serializing of content.

These can still be used but you will see an error indicating that you need to use the use client since these components use React.useState effects which cannot be used on RSC.

In this example, I will be using the newer RSC capable helpers from the next-mdx-remote/rsc additions.

  • complileMDX (RSC) - compiles on the server side only
  • <MDXRemote> (RSC) - renders on server side only

It accepts similar options for plugins and components as we saw in the local setup.


import { MDXRemote } from 'next-mdx-remote/rsc';

function Page() {

  const mdxOptions = {
    source,
    options: {
      parseFrontmatter: true,
      mdxOptions: {
        remarkPlugins: [],
        rehypePlugins: []
      },
    },
    components: { },
  };
  
  return <MDXRemote {...mdxOptions}></MDXRemote>;
}


The <MDXRemote> component does all of the heavy lifting and calls compileMDX behind the scenes.

If you wanted to explicitly call the compileMDX function, here's what that would look like.


import { CompileMDXResult, compileMDX, MDXRemote } from 'next-mdx-remote/rsc';

async function Page() {

  const { frontmatter, content } = await compileMDX({
    source,
    options: {
      parseFrontmatter: true,
      mdxOptions: {
        remarkPlugins: [],
        rehypePlugins: []
      },
    },
    components: { },
  });
  
  return <>{content}</>;
}


Remote MDX Example

For this section we'll create a documents viewer to load MDX content from the underlying file system based on a slug path param in the url.

This example could be used for something like product information or even online help for that matter. It will consist of following directory structure.


 your-project
  ├── app
  │   └── documents
  │       └── [slug]
  │           └── page.tsx
  ├── components
  │   └── mdx
  │       ├── markdown.tsx
  │       ├── hero.tsx
  │       └── layouts
  │           └── document.tsx
  └── content
      └── documents
          ├── mdx-local.mdx
          └── mdx-remote.mdx

All of the MDX content will now live in the /content/documents directory. Also note, these pages are using the .mdx extension and the actual filename will be used as the slug.

So each page can be viewed and accessed using the localhost:3000/documents/mdx-remote url.

Let's start by adding the app router page.tsx with a dynamic slug path param.


/app/documents/[slug]/page.tsx
import fs from 'fs';
import path from 'path';
import { MDXRemote } from 'next-mdx-remote/rsc';
import Markdown from '@/components/mdx/markdown';
import Hero from '@/components/mdx/hero';

type Props = {
  params: {
    slug: string;
  };
};

const documents = path.join(process.cwd(), 'content', 'documents');

async function DocumentPage({ params: { slug } }: Props) {

  const getDocumentBySlug = async (slug: string): Promise<string> => {
    const fileName = path.join(documents, `${slug}.mdx`);
    const fileContent = fs.readFileSync(fileName, { encoding: 'utf8' });
    return fileContent;
  };

  const source = await getDocumentBySlug(slug);
  const remoteMdxOptions = {
    source,
    options: {
      parseFrontmatter: true,
      mdxOptions: {
        remarkPlugins: [],
        rehypePlugins: []
      },
    },
    components: {
      ...Markdown,
      Hero
    },
  };
  
  return <MDXRemote {...remoteMdxOptions}></MDXRemote>;
}

export default DocumentPage;


Next, I'm adding all of the Markdown HTML customized tags in this markdown.tsx file to make it easier to manage any theme changes to the default Markdown tags.

Notice that the Hero, Markdown and any plugins are added to the remoteMdxOptions.

This is a little different from the local approach where we defined it once in next.config.mjs. Using the remote approach gives some flexibility to have different configurations for different use cases.


/components/mdx/markdown.tsx
import React from 'react';

const Markdown = {
  h1: ({ children, ...props }: React.ComponentPropsWithoutRef<'h1'>) => {
    return (
      <h1 {...props} className="my-8">
        {children}
      </h1>
    );
  },
  h2: ({ children, ...props }: React.ComponentPropsWithoutRef<'h2'>) => {
    return (
      <h2 {...props} className="my-6">
        {children}
      </h2>
    );
  },
  h3: ({ children, ...props }: React.ComponentPropsWithoutRef<'h3'>) => {
    return (
      <h2 {...props} className="my-5">
        {children}
      </h2>
    );
  },
  h4: ({ children, ...props }: React.ComponentPropsWithoutRef<'h4'>) => {
    return (
      <h4 {...props} className="my-4">
        {children}
      </h4>
    );
  },
};

export default Markdown;

Here's the MDX content for the two files the DocumentPage will load.


/content/documents/mdx-local.mdx
---
title: 'MDX Local Content'
---

<Hero background={{type: 'gradient'}} heroText={frontmatter.title}/>

A simple example of configuring MDX local setup.


/content/documents/mdx-remote.mdx
---
title: 'MDX Remote Content'
---
<Hero background={{type: 'gradient'}} heroText={frontmatter.title}/>

A simple example of configuring MDX remote setup.


MDX Remote Output

If you run npm run dev and point your browser to either of the two pages.

  • localhost:3000/documents/mdx-local
  • localhost:3000/documents/mdx-remote

you will see the following rendered HTML output.

Output:

MDX Remote Output Image

Remote MDX Front Matter

The <MDXRemote> component will automatically parse the frontmatter without the need for additional Front Matter plugins.

As you can see in both of the Markdown pages that I set the value of the heroText prop, to {frontmatter.title} since the frontmatter is available in the MDX content scope now.


Remote MDX Layouts

As far as layouts for remote MDX Markdown, we'll need to be a little more creative since the import and export ESM syntax is not available.

So, even if you tried to export a default layout from the Markdown .mdx content page, it would be ignored when parsing the content.

Remote MDX Layouts

However, we can still reuse the DocumentLayout we defined earlier and use the compileMDX function.


import DocumentLayout from '@/components/mdx/layouts/document';

async function DocumentPage({ params: { slug } }: Props) {

  // omitted for brevity
  ...

  const {frontmatter, content} = await compileMDX({
    // mdx remote options here
  });
  
  return (
    <DocumentLayout {...frontmatter}>
      {content}
    </DocumentLayout>
  );
}


The DocumentLayout is not parsed by the next-mdx-remote parser so the content has to be compiled and parsed first to extract the frontmatter. It can then be passed as props to the DocumentLayout.

This has some pretty good advantages since we can swap different layouts for things like BlogLayout or even ProductLayout for example.


Using Wrapper for MDX Layouts

Another option would be to use the wrapper function for layouts but the frontmatter will not be available when using this approach.

This approach would be better suited for cases when you're content is coming from a database or CMS system and the meta data will be supplied by those sources. For example:



import DocumentLayout from '@/components/mdx/layouts/document';

async function DocumentPage({ params: { slug } }: Props) {

  const getDocumentBySlug = async (slug: string): Promise<string> => {
   return await getDocumentFromApi(slug);
  };

  // record from database or CMS with meta and source information
  const document = await getDocumentBySlug(slug);

  const mdxRemoteOptions = {
    source: document.source,
    components: {
      wrapper: ({ children }: PropsWithChildren) => {
        return (
          <DocumentLayout title={document.title}>{children}<DocumentLayout>
        );
      }, 
    },
  };
  
  return <MDXRemote {...mdxRemoteOptions}></MDXRemote>;
}


This is probably a better approach if your content is not using Front Matter and the page meta data is externally supplied.


Conclusion

That's all for this article and I hope it helps you on your MDX path.

By now you should have a pretty good idea of what MDX is and how it can be applied.

You should also have a good grasp on both ways to setup and use MDX on Next.js. Both options can be used to build great websites, blogs and applications.

There is no doubt in my mind that MDX will continue to flourish. It's definitely a great way to take writing page content to a whole new level and beyond just a README document.

Topics

SEOLinuxSecuritySSHEmail MarketingMore posts...

Related Posts

Hero image for Boost Payload CMS with Search: Step-by-Step Tutorial
Posted on: August 11 2025
By Dave Becker
Boost Payload CMS with Search: Step-by-Step Tutorial
Hero image for Server-Side Pagination Made Easy in Payload CMS
Posted on: August 11 2025
By Dave Becker
Server-Side Pagination Made Easy in Payload CMS
Hero image for Payload CMS: Getting Started Using the New Join Field
Posted on: August 11 2025
By Dave Becker
Payload CMS: Getting Started Using the New Join Field
Hero image for Maximizing Efficiency: The Power of Payload CMS Blocks
Posted on: August 11 2025
By Dave Becker
Maximizing Efficiency: The Power of Payload CMS Blocks
Hero image for Create Custom Forms Using Payload CMS Form Builder Plugin
Posted on: August 11 2025
By Dave Becker
Create Custom Forms Using Payload CMS Form Builder Plugin
Hero image for Payload CMS SEO Plugin: Boosting Your Site's Search Ranking
Posted on: April 04 2025
By Dave Becker
Payload CMS SEO Plugin: Boosting Your Site's Search Ranking
Hero image for GraphQL Optimization in Payload CMS
Posted on: April 04 2025
By Dave Becker
GraphQL Optimization in Payload CMS
Hero image for Exploring the Game-Changing Features of Payload CMS 3.0
Posted on: April 04 2025
By Dave Becker
Exploring the Game-Changing Features of Payload CMS 3.0
Hero image for Document Nesting With Payload's Nested Docs Plugin
Posted on: April 04 2025
By Dave Becker
Document Nesting With Payload's Nested Docs Plugin
Hero image for Payload CMS Collections: How They Streamline Content Management
Posted on: April 04 2025
By Dave Becker
Payload CMS Collections: How They Streamline Content Management
Trendy Coder Logo
Resources
  • Blog
Website
  • Home
  • About us
Subscribe

Get the latest news and articles to your inbox periodically.

We respect your email privacy

© TrendyCoder.com. All rights reserved.