From Zero to Published: Building a Full-Stack Portfolio - 4

In this post, I will continue from the previous post and introduce how I managed the blog posts.
What is MDX?
MDX stands for Markdown + JSX
.
- You can format text using Markdown syntax, just like in Markdown files (.md).
- You can use JSX and React components.
Markdown
As developers, we usually place a README.md
file at the top level of a module, describing the module, build methods, deployment methods, etc. So, the .md file extension is not unfamiliar. In the example below, you can declare an h1 level heading with a single #
, and create lists using -
.
# Title 1 (h1)
## Title 2 (h2)
- Item 1
- Item 2
- Sub item 1
- Sub item 2
Markdown is a lightweight markup language
that is much more concise than HTML and has a very human-readable syntax structure. It is widely used because it can be written in an .md file and then converted to HTML for display in a browser.
Note here that 1. It can be rendered in HTML
and 2. It's easy for humans to read
.
JSX (React component)
JSX, JavaScript XML
, is a syntax introduced by Facebook as part of the React library.
It's a syntax extension that allows developers to use XML (a syntax similar to HTML) within JavaScript code, enabling declarative UI writing in React. Let's look at the example code below.
function App() {
const name = "Jinyoung";
return (
<div>
<h1>Hello, {name}!</h1>
<p>XML in Javascript!</p>
</div>
);
}
The above JSX code, when transpiled, looks like this:
function App() {
const name = "Jinyoung";
return React.createElement(
'div',
null,
React.createElement(
'h1', null, "Hello, ", name, "!"
),
React.createElement(
'p', null, "XML in Javascript!"
)
);
}
Because you can declare components and build UI using a syntax very similar to HTML, developers can create applications with an intuitive understanding of the code structure. It also integrates naturally with JavaScript code. In the example code, you can see the name
variable being inserted into the UI using curly braces ({}
).
Another advantage is that you can create a UI component once and reuse it elsewhere.
function Button({ label }) {
return <button style={{ padding: "10px", fontSize: "16px" }}>{label}</button>;
}
function App() {
return (
<div>
<Button label="Confirm" />
<Button label="Cancel" />
</div>
);
}
In the above example, you can see the Button
component being reused in the App component. Once you've created a UI component well, you can reuse it in various ways elsewhere.
The overall process is as shown above. You don't need to fully understand this diagram right away. You'll be able to grasp it gradually and clearly as you read through this article and see the configurations and code examples.
Why MDX?
MDX, Markdown + JSX
, is a file format that combines the advantages of both Markdown and JSX.
Advantages of Markdown:
- Can be rendered as HTML
- Human-readable syntax structure
Advantages of JSX:
- Declarative UI component development
- Reusable UI insertion
Feature | Markdown | JSX | MDX |
---|---|---|---|
Concise Syntax | ✅ | ❌ | ✅ |
Convertible to HTML | ✅ | ✅ | ✅ |
React Component Usable | ❌ | ✅ | ✅ |
Reusable UI Insertion | ❌ | ✅ | ✅ |
Dynamic Data Integration | ❌ | ✅ | ✅ |
In summary, MDX takes only these advantages, maintaining the convenience of Markdown while allowing dynamic UI composition using JSX, making it the optimal document format.
Now, let's look at an example of writing an MDX file.
import Button from './Button';
# 🌟 The Power of MDX: Markdown + JSX
You can use standard Markdown syntax in MDX.
- Concise syntax
- Familiar structure
- But **JSX is also possible!** 🎉
## 🎯 Example of Using JSX Components
Click the button below!
<Button label="Click!" />
- Line 1: Imports the previously written
Button
component. Used on line 15. - Line 3-13: Standard Markdown syntax.
- Line 15: Declares the Button component imported on line 1. A button with the text '클릭하세요!' is rendered on the screen.
Reasons for Choosing MDX
As seen above, using the MDX format allows you to use Markdown for the usual text writing needs, and JSX for additional UI styling. Therefore, from a writing perspective, everything needed can be implemented through MDX.
However, before actually writing, it was unclear how much JSX code would be included in the blog posts. But at the time of writing this, I've written about 6-7 blog posts, and surprisingly, a lot of JSX code has been inserted into the posts. I will introduce in detail which components were declared and used in MDX in another post.
MDX Libraries
So far, we've looked at what the MDX file format is and its advantages. Now, let's learn about MDX libraries. We'll see what libraries are available and their respective pros and cons.
Contentlayer
This is the first library I used to implement the blog system. I followed a YouTube clone coding video for the blog system, and that video used this library.
For more details on this library, please refer to the official development documentation page.
Let's briefly look at how to integrate Contentlayer with Next.js.
1. Setup
npm install contentlayer next-contentlayer date-fns
- Install related libraries.
// next.config.js
const { withContentlayer } = require('next-contentlayer')
/** @type {import('next').NextConfig} */
const nextConfig = { reactStrictMode: true, swcMinify: true }
module.exports = withContentlayer(nextConfig)
- This setting allows Contentlayer to be built together during the
next dev
andnext build
processes.
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
// ^^^^^^^^^^^
"paths": {
"contentlayer/generated": ["./.contentlayer/generated"]
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
}
},
"include": [
".contentlayer/generated"
// ^^^^^^^^^^^^^^^^^^^^^^
]
}
- Modify the tsconfig.json or jsconfig.json file as above. If the existing items are already present in the file, you should add to them; otherwise, you need to create new items.
# .gitignore
# ...
# contentlayer
.contentlayer
- Add
.contentlayer
to the.gitignore
file. This is because the build artifacts generated by Contentlayer do not need to be managed by Git.
2. Content Schema
// contentlayer.config.ts
import { defineDocumentType, makeSource } from 'contentlayer/source-files'
export const Post = defineDocumentType(() => ({
name: 'Post',
filePathPattern: `**/*.mdx`,
contentType: 'mdx',
fields: {
title: { type: 'string', required: true },
date: { type: 'date', required: true },
},
computedFields: {
url: { type: 'string', resolve: (post) => `/posts/${post._raw.flattenedPath}` },
},
}))
export default makeSource({ contentDirPath: 'posts', documentTypes: [Post] })
- Create a
contentlayer.config.ts
file at the top level of the module. - Line 6-7: Settings for the mdx file.
- Line 8-14: Declaration of the schema structure for managing the content of the MDX file.
3. Create a content
Now that the setup is complete, it's time to write the MDX file.
# Hello, World!
This is my first MDX file. Here's a button element <button>Click me!</button>.
<MyComponent />
- Create a
posts
directory at the top level of the module and write the mdx file under it.- ex:
posts/post-01.mdx
- ex:
4. Create a compoent for MDX
The MDX file must ultimately be converted into a React component before it can be rendered on the screen. Therefore, you need to declare a component to handle this.
// app/posts/[slug]/page.tsx
import { allPosts } from 'contentlayer/generated'
import { useMDXComponent } from 'next-contentlayer/hooks'
import { notFound } from 'next/navigation'
export async function generateStaticParams() {
return allPosts.map((post) => ({
slug: post._raw.flattenedPath,
}))
}
export default async function Page({ params }: { params: { slug: string } }) {
// Find the post for the current page.
const post = allPosts.find((post) => post._raw.flattenedPath === params.slug)
// 404 if the post does not exist.
if (!post) notFound()
// Parse the MDX file via the useMDXComponent hook.
const MDXContent = useMDXComponent(post.body.code)
return (
<div>
{/* Some code ... */}
<MDXContent />
</div>
)
}
- The file is created in
app/posts/[slug]/page.tsx
. - Line 2: Imports the entire list of posts built by Contentlayer.
- Line 14: Finds the single Post that matches the
[slug]
parameter. - Line 20: Uses the
useMdxComponent
hook to parse the MDX file in Next.js. - Line 25: Places
MDXContent
on the screen.
So far, we've learned how to use the Contentlayer library to parse and render MDX in a Next.js application.
Contentlayer works very well when combined with Next.js, and the development documentation is also well written. However, it does not support the latest version of React.js, and the maintainer has stated that it is no longer maintained. There is a fork version called contentlayer2, so those interested might want to take a look.
Since it is impossible to build a blog system with the latest version of Next.js (React) using Contentlayer, I decided to look for an alternative. The next thing I looked at was @next/mdx
, a library officially supported by Next.js.
@next/mdx
The @next/mdx
package is a library used to configure Next.js to handle Markdown or MDX.
Since it is officially supported by Next.js, installation and setup are very easy. Let's learn how to set up @next/mdx below.
1. Set up
npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
- Install the related libraries.
import type { MDXComponents } from 'mdx/types'
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
}
}
- Create the mdx-components.tsx file in the
src
directory.
const withMDX = require('@next/mdx')()
/** @type {import('next').NextConfig} */
const nextConfig = {
// Configure `pageExtensions` to include MDX files
pageExtensions: ['js', 'jsx', 'mdx', 'md', 'ts', 'tsx'],
// Optionally, add any other Next.js config below
}
module.exports = withMDX(nextConfig)
- Modify the Next.js config file (next.config.js or next.config.mjs) as above.
- Line 6: Add
mdx
to pageExtensions in nextConfig. - Line 10: Wrap nextConfig with withMdx and assign it to module.exports.
- Line 6: Add
2. Create a content
your-project
├── app
│ └── my-mdx-page
│ └── page.mdx
└── package.json
- Create an MDX file under the
app
directory and fill in the content.
Then, when you navigate to the /my-mdx-page
page, you can see that page.mdx is rendered.
That's all the code you need to render MDX using @next/mdx. You can see that the setup is much simpler compared to Contentlayer, which we looked at earlier. Also, if you create another directory under the app
directory and place page.mdx
inside it, you can see that the directory becomes a page, which is the same as Next.js's file-based routing system.
However, Next.js versions 14 and 15 have key differences regarding the MDX rendering method.
Next.js v14 vs v15
In v14, MDX files must be managed under the /app
directory (based on App Router).

As seen in the settings above, you need to place page.mdx under the /app/my-mdx-page
directory, and this will automatically be handled by the file-based routing system, so when a user accesses /my-mdx-page
, page.mdx will be rendered.
However, in v15, dynamic imports are possible.

The image above (a screenshot of the official Next.js v15 documentation) shows that MDX files can be managed in a separate directory (content) from the app
directory. Of course, v15 also supports the existing file-based routing method.
Schema Not Supported
One of the advantages of Contentlayer is that you can specify and use the schema of your blog posts. It seems that there is no way to specify the schema like this in @next/mdx.
Metadata Not Supported
@next/mdx does not natively support extracting metadata from blog posts. You should be able to specify the blog post title, cover image, author, etc. in MDX using a special rule like Frontmatter, but this is not natively supported. Therefore, additional plugin libraries need to be installed.
Velite
Now let's learn about another MDX library called Velite.
Velite is not a mature library like Contentlayer, which has been used by many developers for years. It was first released in early 2024, and as of March 2025, the latest version is 0.2.2
.
1. Set up
npm install velite -D
- Install related libraries.
{
"compilerOptions": {
"paths": {
"#site/content": [
"./.velite"
]
}
},
}
- Modify the tsconfig.json or jsconfig.json file. Set the reference path for the velite build output.
# .gitignore
# ...
# velite
.velite
- Add
.velite
to the.gitignore
file. This is because the build artifacts generated by velite do not need to be managed by Git.
2. Content Schema
import { defineConfig, s } from 'velite'
// `s` is extended from Zod with some custom schemas,
// you can also import re-exported `z` from `velite` if you don't need these extension schemas.
export default defineConfig({
root: "content",
collections: {
posts: {
name: 'Post', // collection type name
pattern: 'posts/**/*.mdx', // content files glob pattern
schema: s
.object({
title: s.string().max(99), // Zod primitive type
slug: s.path(), // auto generate slug from file path
date: s.isodate(), // input Date-like string, output ISO Date string.
cover: s.image(), // input image relative path, output image object with blurImage.
content: s.markdown(), // transform markdown to html
body: s.mdx(),
})
// more additional fields (computed fields)
.transform(data => ({ ...data, permalink: `/blog/${data.slug}` }))
},
others: {
// other collection schema options
}
}
})
- Create a
velite.config.js
file in the root directory of the module. - Line 7: The name of the root directory for the MDX files.
- Line 11: Specifies the path of the MDX files to be processed from the
root
directory. - Line 12-20: Specifies the schema for a single MDX. This specified schema is used in tsx or ts code.
const isDev = process.argv.indexOf('dev') !== -1
const isBuild = process.argv.indexOf('build') !== -1
if (!process.env.VELITE_STARTED && (isDev || isBuild)) {
process.env.VELITE_STARTED = '1'
const { build } = await import('velite')
await build({ watch: isDev, clean: !isDev })
}
/** @type {import('next').NextConfig} */
export default {
// next config here...
}
- Add the above code to the top of the Next.js configuration file (next.config.js or next.config.mjs). This is a setting that causes Velite build to be performed during Next.js build.
3. Create a content
root
+├── content
+│ ├── posts
+│ │ └── hello-world.mdx
├── public
├── package.json
└── velite.config.js
- Create a
content/posts
directory at the top level of the module and write the MDX file inside it.
4. Create a compoent for MDX
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 }} />
}
- Create a tsx file as above with the name and location you want.
import { posts } from '#site/content'
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.body} components={{ Chart }} />
</article>
)
}
- Create a page.tsx file under the
app/posts/[...slug]
directory. This is the component that handles pages with paths likeposts/hello-world
. - Line 1: Imports posts from the velite build output directory (
.velite
). - Line 3 & 10: By declaring the MDXContent component, the MDX is finally rendered on the screen.
This is the setup for velite. You can see that it is very similar to Contentlayer.
Final MDX Library Selection
So far, we have looked at a total of 3 MDX libraries. I will introduce the pros and cons of each library and the library I chose to implement the blog system.
- Contentlayer
- Advantages: Since it has been developed and operated for several years, there are abundant references. Also, the development documentation is relatively well written.
- Disadvantages: As mentioned above, it is no longer a maintained library. There is a fork version, but I don't know how well it will be operated. It doesn't work well with the latest version of Next.js (React).
- @next/mdx
- Advantages: Officially supported by the Next.js team. Very easy to set up.
- Disadvantages: In Next.js v14, only file-based routing MDX rendering was possible, so MDX files had to be placed inside the app directory. Also, schema support is not natively provided.
- Velite
- Advantages: You can specify a schema to be type-safe. You can manage content outside of the app directory. And it is a library that is currently actively managed and operated, and integration with the latest Next.js works smoothly.
- Disadvantages: Relatively poor development documentation.
When implementing the blog system, I initially used Contentlayer. But when I found out that it was no longer maintained, I tried using @next/mdx next. At that time, the module's Next.js version was 14, and this version did not allow managing content externally, so I started looking for another library. And the last one I found was Velite.
Velite seems to have inherited almost all the advantages of Contentlayer. Even the official Velite documentation states that it was inspired by Contentlayer.
Velite has many advantages. It is type-safe and custom types can be declared. And it is possible to manage content in an external directory. On the other hand, since it is a new library that started about a year ago, the development documentation and supported features may be lacking compared to others. The basic functions for managing blog posts are faithfully implemented, but when there are things needed while operating the service in the future, it may not be supported at the library level.
After considering these pros and cons, I decided to use Velite. As of March 2025, I am building and operating a blog system with Velite, and I haven't found any problems yet.
Closing
As we have seen above, the configuration methods are almost the same for each MDX library, although the names are slightly different. You change the settings to build MDX at Next.js build time and use the MDX build output in the TSX component. The process of changing from Contentlayer to @next/mdx and then to Velite (although there was some digging in the middle) didn't take that long.
Perhaps the most important thing is the contents themselves, rather than the library. 🧐
Now it's time to end this post.
In this post, I introduced what MDX is, why I use it, and what libraries I considered. In the next post, I will explore the MDX plugins I use to make my blog posts richer.
Thank you for reading to the end!
Comments (0)
Checking login status...
No comments yet. Be the first to comment!