Skip to content

MDX Support ​

Velite supports MDX out of the box. You can use MDX to write your content, and Velite will automatically render it for you.

Some examples that may help you:

Getting Started ​

For example, suppose you have the following content structure:

diff
project-root
├── content
│   └── posts
│       └── hello-world.mdx
├── public
├── package.json
└── velite.config.js

The./content/posts/hello-world.mdx document is a MDX document with the following content:

mdx
---
title: Hello world
---

export const year = 2023

# Last year’s snowfall

In {year}, the snowfall was above average.
It was followed by a warm spring which caused
flood conditions in many of the nearby rivers.

<Chart year={year} color="#fcb32c" />

Use the s.mdx() schema to add the compiled MDX code to your content collection.

js
import { defineConfig, s } from 'velite'

export default defineConfig({
  collections: {
    posts: {
      name: 'Post',
      pattern: 'posts/*.mdx',
      schema: s.object({
        title: s.string(),
        code: s.mdx()
      })
    }
  }
})

Run velite build and you will get the following data structure:

json
{
  "posts": [
    {
      "title": "Hello world",
      "code": "const{Fragment:n,jsx:e,jsxs:t}=arguments[0],o=2023;function _createMdxContent(r){const a={h1:\"h1\",p:\"p\",...r.components},{Chart:c}=a;return c||function(n,e){throw new Error(\"Expected \"+(e?\"component\":\"object\")+\" `\"+n+\"` to be defined: you likely forgot to import, pass, or provide it.\")}(\"Chart\",!0),t(n,{children:[e(a.h1,{children:\"Last year’s snowfall\"}),\"\\n\",t(a.p,{children:[\"In \",o,\", the snowfall was above average.\\nIt was followed by a warm spring which caused\\nflood conditions in many of the nearby rivers.\"]}),\"\\n\",e(c,{year:o,color:\"#fcb32c\"})]})}return{year:o,default:function(n={}){const{wrapper:t}=n.components||{};return t?e(t,{...n,children:e(_createMdxContent,{...n})}):_createMdxContent(n)}};"
    }
  ]
}

By default, Velite will compile the MDX content into a function-body string, which can be used to render the content in your application.

Rendering MDX Content ​

First, you can create a generic component for rendering the compiled mdx code. It should accept the code and a list of components that are used in the MDX content.

./components/mdx-content.tsx:

tsx
import * as runtime from 'react/jsx-runtime'

const sharedComponents = {
  // Add your global components here
}

// parse the Velite generated MDX code into a React component function
const useMDXComponent = (code: string) => {
  const fn = new Function(code)
  return fn({ ...runtime }).default
}

interface MDXProps {
  code: string
  components?: Record<string, React.ComponentType>
}

// MDXContent component
export const MDXContent = ({ code, components }: MDXProps) => {
  const Component = useMDXComponent(code)
  return <Component components={{ ...sharedComponents, ...components }} />
}

Then, you can use the MDXContent component to render the MDX content:

./pages/posts/[slug].tsx:

tsx
import { posts } from '@/.velite'
import { Chart } from '@/components/chart' // import your custom components
import { MDXContent } from '@/components/mdx-content'

export default function Post({ params: { slug } }) {
  const post = posts.find(i => i.slug === slug)
  return (
    <article>
      <h1>{post.title}</h1>
      <MDXContent code={post.code} components={{ Chart }} />
    </article>
  )
}

FAQ ​

How to import components in MDX? ​

You don't need to, since Velite's s.mdx() schema does not bundle those components at build time. There is no need to construct a import tree. This can help reduce output size for your contents.

For example, suppose you extract a common component for multiple MDXs and import the component in these MDXs.

tsx
export const Callout = ({ children }: { children: React.ReactNode }) => {
  // your common component
  return <div style={{ border: '1px solid #ddd', padding: '1rem' }}>{children}</div>
}
mdx
---
title: Foo
---

import { Callout } from '../components/callout'

# Foo

<Callout>This is foo callout.</Callout>
mdx
---
title: Bar
---

import { Callout } from '../components/callout'

# Bar

<Callout>This is bar callout.</Callout>

If Velite uses a bundler to compile your MDX, the Callout component will be bundled into each MDX file, which will cause a lot of redundancy in the output code.

Instead, simply use whatever components you want in your MDX files without a import.

mdx
---
title: Foo
---

# Foo

<Callout>This is foo callout.</Callout>
mdx
---
title: Bar
---

# Bar

<Callout>This is bar callout.</Callout>

Then, inject the components into the MDXContent component:

tsx
import { Callout } from '@/components/callout'
import { MDXContent } from '@/components/mdx-content'

export default function Post({ params: { slug } }) {
  const post = posts.find(i => i.slug === slug)
  return (
    <article>
      <h1>{post.title}</h1>
      <MDXContent code={post.code} components={{ Callout }} />
    </article>
  )
}

You can also add global components so that they are available to all MDX files.

tsx
import * as runtime from 'react/jsx-runtime'

import { Callout } from '@/components/callout'

const sharedComponents = {
  // Add your global components here
  Callout
}

const useMDXComponent = (code: string) => {
  const fn = new Function(code)
  return fn({ ...runtime }).default
}

interface MDXProps {
  code: string
  components?: Record<string, React.ComponentType>
}

export const MDXContent = ({ code, components }: MDXProps) => {
  const Component = useMDXComponent(code)
  return <Component components={{ ...sharedComponents, ...components }} />
}

What if I want to bundle MDX? ​

If you can make do with the increased output size, bundling MDX can be a good option for better portability.

You can install the following packages to bundle MDX:

bash
npm i esbuild @fal-works/esbuild-plugin-global-externals @mdx-js/esbuild --save-dev

Then, create a custom schema for MDX bundling:

CAUTION

The following code is just a simple example. You may need to adjust it according to your actual situation.

ts
import { dirname, join } from 'node:path'
import { globalExternals } from '@fal-works/esbuild-plugin-global-externals'
import mdxPlugin from '@mdx-js/esbuild'
import { build } from 'esbuild'
import { context, s } from 'velite'

import type { Plugin } from 'esbuild'

const compileMdx = async (source: string, path: string, options: CompileOptions): Promise<string> => {
  const virtualSourse: Plugin = {
    name: 'virtual-source',
    setup: build => {
      build.onResolve({ filter: /^__faker_entry/ }, args => {
        return {
          path: join(args.resolveDir, args.path),
          pluginData: { contents: source } // for mdxPlugin
        }
      })
    }
  }

  const bundled = await build({
    entryPoints: [`__faker_entry.mdx`],
    absWorkingDir: dirname(path),
    write: false,
    bundle: true,
    target: 'node18',
    platform: 'neutral',
    format: 'esm',
    globalName: 'VELITE_MDX_COMPONENT',
    treeShaking: true,
    jsx: 'automatic',
    minify: true,
    plugins: [
      virtualSourse,
      mdxPlugin({}),
      globalExternals({
        react: {
          varName: 'React',
          type: 'cjs'
        },
        'react-dom': {
          varName: 'ReactDOM',
          type: 'cjs'
        },
        'react/jsx-runtime': {
          varName: '_jsx_runtime',
          type: 'cjs'
        }
      })
    ]
  })

  return bundled.outputFiles[0].text.replace('var VELITE_MDX_COMPONENT=', 'return ')
}

export const mdxBundle = (options: MdxOptions = {}) =>
  s.custom<string>().transform<string>(async (value, { addIssue }) => {
    const { config, file } = context()
    const { path, content } = file

    value = value ?? content
    if (value == null) {
      addIssue({ fatal: true, code: 'custom', message: 'The content is empty' })
      return null as never
    }

    const enableGfm = options.gfm ?? config.mdx?.gfm ?? true
    const enableMinify = options.minify ?? config.mdx?.minify ?? true
    const removeComments = options.removeComments ?? config.mdx?.removeComments ?? true
    const copyLinkedFiles = options.copyLinkedFiles ?? config.mdx?.copyLinkedFiles ?? true
    const outputFormat = options.outputFormat ?? config.mdx?.outputFormat ?? 'function-body'

    const remarkPlugins = [] as PluggableList
    const rehypePlugins = [] as PluggableList

    if (enableGfm) remarkPlugins.push(remarkGfm) // support gfm (autolink literals, footnotes, strikethrough, tables, tasklists).
    if (removeComments) remarkPlugins.push(remarkRemoveComments) // remove html comments
    if (copyLinkedFiles) remarkPlugins.push([remarkCopyLinkedFiles, config.output]) // copy linked files to public path and replace their urls with public urls
    if (options.remarkPlugins != null) remarkPlugins.push(...options.remarkPlugins) // apply remark plugins
    if (options.rehypePlugins != null) rehypePlugins.push(...options.rehypePlugins) // apply rehype plugins
    if (config.mdx?.remarkPlugins != null) remarkPlugins.push(...config.mdx.remarkPlugins) // apply global remark plugins
    if (config.mdx?.rehypePlugins != null) rehypePlugins.push(...config.mdx.rehypePlugins) // apply global rehype plugins

    const compilerOptions = { ...config.mdx, ...options, outputFormat, remarkPlugins, rehypePlugins }

    try {
      return await compileMdx(value, path, compilerOptions)
    } catch (err: any) {
      addIssue({ fatal: true, code: 'custom', message: err.message })
      return null as never
    }
  })

Then, you can use the custom schema in your velite.config.js:

js
import { defineConfig, s } from 'velite'

import { mdxBundle } from './mdx'

export default defineConfig({
  collections: {
    posts: {
      name: 'Post',
      pattern: 'posts/*.mdx',
      schema: s.object({
        title: s.string(),
        code: mdxBundle()
      })
    }
  }
})

Distributed under the MIT License.