By the end you'll know
- What MDX is, and why sites write content in it
- How front matter holds a post's title, date and status
- How to load MDX files, list them, and turn each into a page
- How to let writers use your own components inside their posts
- How to keep drafts out of production
- Why MDX is code, what that means for braces and angle brackets, and which settings keep it safe
Before you start
- You need: Lesson 4: the bakery's Next.js app. Some Markdown helps.
- Versions: Next.js 16.4.5, next-mdx-remote 6.0.0 and gray-matter 4.0.3, the latest releases in November 2026. The safety behaviour below is specific to next-mdx-remote 6; older versions behave differently.
- The example: Juniper & Rye, the made-up bakery. Every post, build and line of output below is real.
The short version
- MDX is Markdown plus components. Writers get headings, lists and links; you give them
<Callout>and<Price>. - Front matter at the top of each file holds the title, date, summary and status. gray-matter splits it from the body.
- Each post becomes a static page at build time. A draft showed in development and returned 404 in production.
- MDX is code. Braces are JavaScript and
<starts a tag.{free}vanished from a sentence without a warning;We <3 ryefailed the build. - next-mdx-remote 6 blocks JavaScript by default. Turn that off and a post can run code on your build server; turn off the second switch and a post printed a secret key onto the page.
- Escape braces with a backslash (
\{), and scan for unescaped ones in CI.
Why MDX
The bakery wants a news page: rye week, a new oven, holiday orders. Writing each post as a React component would work, but whoever writes the news shouldn't need to know React. They should write text.
Markdown is the simple way to write formatted text: **bold**, ## headings, - lists, [links](/menu). MDX is Markdown that can also use components, written like HTML tags. The writer gets Markdown; the site gets to offer a <Callout> for announcements and a <Price> that always shows the current price from the menu data. This site's lessons are written the same way.
Each post is one file:
content/news/
├── rye-week.mdx published
├── new-oven.mdx published
└── holiday-orders.mdx draft
Here's rye-week.mdx:
---
title: "Rye week is back"
date: "2026-11-02"
summary: "Five days of rye: a new seeded loaf, rye cookies, and a free tasting on Saturday."
status: published
---
Every November we spend a week on rye, and this year it starts **Tuesday**.
<Callout>
Free tasting on Saturday, 10am to noon. No booking needed.
</Callout>
## What's new
- A **seeded rye** with sunflower and pumpkin seeds
- Rye chocolate cookies, while they last
- Our usual dark rye, <Price slug="dark-rye" /> as always
Read more about [how we bake rye](/menu/dark-rye).
- Front matter is the block between the
---lines, written in YAML:key: valuepairs about the post, not part of its text. statusis the bakery's own convention:publishedordraft.<Callout>and<Price slug="dark-rye" />are components the site provides. The writer doesn't know how they work, only what they do.
Loading the posts
The app needs two packages:
npm install next-mdx-remote@6.0.0 gray-matter@4.0.3
gray-matter splits a file into front matter and body. next-mdx-remote compiles MDX from anywhere (files, a database, a content service) into React, on the server. A small module loads the posts:
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
const DIR = path.join(process.cwd(), "content", "news");
const SHOW_DRAFTS = process.env.NODE_ENV !== "production";
// Every post's front matter, newest first. Drafts only outside production.
export function allPosts() {
return fs.readdirSync(DIR)
.filter((f) => f.endsWith(".mdx"))
.map((f) => {
const { data } = matter(fs.readFileSync(path.join(DIR, f), "utf8"));
return { slug: f.replace(/\.mdx$/, ""), ...data };
})
.filter((p) => p.status === "published" || SHOW_DRAFTS)
.sort((a, b) => b.date.localeCompare(a.date));
}
// One post: its front matter and its MDX body, or null.
export function getPost(slug) {
if (!/^[a-z0-9-]+$/.test(slug)) return null; // no ../ tricks
const file = path.join(DIR, `${slug}.mdx`);
if (!fs.existsSync(file)) return null;
const { data, content } = matter(fs.readFileSync(file, "utf8"));
if (data.status !== "published" && !SHOW_DRAFTS) return null;
return { slug, ...data, content };
}
fs.readdirSynclists the files incontent/news; this code runs on the server (or at build time), never in the browser.matter(...)returnsdata(the front matter as an object) andcontent(the body).- The slug is the file name without
.mdx:rye-week.mdxbecomes/news/rye-week. SHOW_DRAFTSis true in development and false in production builds.next devsetsNODE_ENVtodevelopment;next buildsets it toproduction.- Sorting by
dateworks as text because the dates are writtenYYYY-MM-DD. - The slug check in
getPostallows only lowercase letters, digits and dashes, so a URL like/news/..%2F..%2Fsecretscan't read a file outside the folder.
Components for writers
import { findBake } from "../../data/menu";
// Components that MDX files can use by name.
function Callout({ children }) {
return <aside className="callout">{children}</aside>;
}
function Price({ slug }) {
const bake = findBake(slug);
return bake ? <strong className="price">${bake.price.toFixed(2)}</strong> : <em className="price">price coming soon</em>;
}
export const mdxComponents = { Callout, Price };
Price reads the same menu data as Lesson 4's pages, so when the price of dark rye changes in one place, every post that mentions it changes too. A writer who misspells a slug gets "price coming soon" instead of a crash.
A page for each post
import { notFound } from "next/navigation";
import { MDXRemote } from "next-mdx-remote/rsc";
import { allPosts, getPost } from "../../../lib/news";
import { mdxComponents } from "../../components/mdx";
export function generateStaticParams() {
return allPosts().map((p) => ({ slug: p.slug }));
}
export async function generateMetadata({ params }) {
const post = getPost((await params).slug);
return post ? { title: post.title, description: post.summary } : { title: "Not found" };
}
export default async function NewsPost({ params }) {
const post = getPost((await params).slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<p><time dateTime={post.date}>{post.date}</time></p>
<MDXRemote source={post.content} components={mdxComponents} />
</article>
);
}
It's Lesson 4's bake page again, with MDX. MDXRemote from next-mdx-remote/rsc compiles the post's body on the server and renders it; components tells it what <Callout> and <Price> mean. The front matter supplies the title, the date and the description. A news list page (in the practice folder) shows every post's title, date and summary.
Building it
(Your machine)
npm run build
The end of the build:
Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /api/signup
├ ○ /menu
├ /menu/[slug]
│ ├ ● /menu/country-sourdough
│ ├ ● /menu/dark-rye
│ └ ● /menu/cardamom-bun
├ ○ /news
└ /news/[slug]
├ ● /news/rye-week
└ ● /news/new-oven
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
ƒ (Dynamic) server-rendered on demand
Two posts, two pages. holiday-orders.mdx isn't there: it's a draft, and this is a production build.
Checking it in production and development
A short Playwright script (in the practice folder) reads the news list, one post and the draft, from the production server (npm start) and from the development server (npm run dev):
== production (npm start)
-- http://127.0.0.1:3000/news
Rye week is back 2026-11-02 · Five days of rye: a new seeded loaf, rye cookies, and a free tasting on Saturday.
We have a new oven 2026-09-15 · After twelve years, the old deck oven has retired. Here's what changes for your bread.
-- /news/rye-week: 200, title "Rye week is back | Juniper & Rye"
callout: "Free tasting on Saturday, 10am to noon. No booking needed."
prices: ["$9.00"]
heading: ["What's new"]
-- /news/holiday-orders: 404, title "404: This page could not be found."
== development (npm run dev)
-- http://127.0.0.1:3002/news
Holiday orders open soon (draft) 2026-11-20 · Pre-order stollen and panettone from December 1.
Rye week is back 2026-11-02 · Five days of rye: a new seeded loaf, rye cookies, and a free tasting on Saturday.
We have a new oven 2026-09-15 · After twelve years, the old deck oven has retired. Here's what changes for your bread.
-- /news/rye-week: 200, title "Rye week is back | Juniper & Rye"
callout: "Free tasting on Saturday, 10am to noon. No booking needed."
prices: ["$9.00"]
heading: ["What's new"]
-- /news/holiday-orders: 200, title "Holiday orders open soon | Juniper & Rye"
callout: null
prices: []
heading: []
- The list is newest first, from the front matter's dates.
- The post rendered fully: the
<Callout>became an<aside>,<Price slug="dark-rye" />became$9.00from the menu data, and## What's newbecame a heading. - The draft shows in development, marked "(draft)", and is a 404 in production. Writers can preview; visitors can't see it.
Two mistakes from building this check are worth knowing. The first version looked for prices with article strong, and found "Tuesday" and "seeded rye" too, because Markdown's **bold** also becomes <strong>. Giving Price its own class fixed it. And one run reported no prices in production at all: the old production server was still running, the new one had failed with "address in use", and the check was reading the previous build. In a process list, a running Next.js server is called next-server, not next start, so the command used to stop it had missed it. When a result makes no sense, check what you're actually talking to.
MDX is code
This is the part that catches people. MDX turns your file into JavaScript, so some ordinary characters mean something:
<followed by a letter or number starts a tag.{and}wrap a JavaScript expression.
Three experiments show what that means in practice.
A brace that ate a word
A new post, cookie-deal.mdx:
Rye cookies are < $3 this week, and every box comes with a {free} sticker.
The build succeeded, with no warnings, and the page read:
Rye cookies are < $3 this week, and every box comes with a sticker.
The word "free" is gone. MDX read {free} as a JavaScript expression, and next-mdx-remote 6 removes expressions by default (more below). No error, no warning: just a missing word on a published page. The < $3 survived only because a space follows the <.
A heart that broke the build
The same post, written as We <3 rye cookies, and this week they're under $3.:
Error occurred prerendering page "/news/cookie-deal". Read more: https://nextjs.org/docs/messages/prerender-error
[Error: [next-mdx-remote] error compiling MDX:
Unexpected character `3` (U+0033) before name, expected a character that can start a name, such as a letter, `$`, or `_`
More information: https://mdxjs.com/docs/troubleshooting-mdx] {
digest: '175688699'
}
Export encountered an error on /news/[slug]/page: /news/cookie-deal, exiting the build.
⨯ Next.js build worker exited with code: 1 and signal: null
MDX saw < and expected a tag name; 3 can't start one. The whole build failed, so no part of the site could be deployed until the post was fixed. That's annoying, and better than the brace: a failed build is loud.
A post that read a secret
The library has two switches for expressions in MDX, and both are on by default in version 6:
blockJSremoves JavaScript expressions entirely. That's what deleted{free}.blockDangerousJS, ifblockJSis turned off, makes a best-effort attempt to block dangerous things likeprocess,evalandrequire.
Someone will eventually want {2 * 2.5} to work, and turn the first switch off. To see what that means, the post became:
Two cookies cost {2 * 2.5} dollars.
Ordering key: {process.env.BAKERY_API_KEY}
process.env.BAKERY_API_KEY is an environment variable on the build server, set here to a fake value, fake-key-for-the-lesson. Then the site was built with each setting, passed to MDXRemote as options:
options={{ blockJS: false }}
build exit 1
Error occurred prerendering page "/news/cookie-deal". Read more: https://nextjs.org/docs/messages/prerender-error
[Error: [next-mdx-remote] error compiling MDX:
Security: Access to 'process' properties is not allowed
More information: https://mdxjs.com/docs/troubleshooting-mdx] {
digest: '1980418648'
}
Export encountered an error on /news/[slug]/page: /news/cookie-deal, exiting the build.
pages containing the key: 0
options={{ blockJS: false, blockDangerousJS: false }}
build exit 0
Cookie deal | 2026-11-05 | Two cookies cost | 5 | dollars. | | Ordering key: | fake-key-for-the-lesson
pages containing the key: 4
- With the defaults, both expressions were removed: the page read "Two cookies cost dollars." and "Ordering key:", with nothing after either. Safe, and wrong.
- With
blockJS: false, the second switch caughtprocessand failed the build. That's a best-effort guard: it blocks known dangerous names, which isn't the same as knowing all of them. - With both off, the page said "Two cookies cost 5 dollars", and then printed the secret key. It ended up in four of the built files, served to every visitor.
The key wasn't the real danger. Code in an MDX file runs on the build server, with access to whatever that server can reach: environment variables, files, the network. With both switches off, whoever can edit an MDX file can run code there. The library's own documentation says it plainly: don't pass user input to it, and only turn off blockDangerousJS for content you completely trust.
Writing MDX safely
- Escape braces with a backslash.
\{free\}shows{free}. Inside backticks (`{free}`) and code blocks, braces are already safe. - Write
<or put a space after<when you mean "less than". In a code span, it's fine as is. - Leave
blockJSon unless you need expressions, and leaveblockDangerousJSon always. If writers need something dynamic, give them a component (like<Price>), not an expression. - Only compile MDX from people you'd trust with your build server. For comments, reviews or anything from the public, use plain Markdown with a library that doesn't run code, or plain text.
- Scan for braces. Silent deletion is the worst kind of bug, so check for it.
A short scanner finds braces outside code:
#!/usr/bin/env python3
"""Find braces in MDX prose: with expressions blocked, they and their contents vanish."""
import glob, re, sys
hits = 0
for f in sorted(glob.glob(sys.argv[1] + "/**/*.mdx", recursive=True)):
text = re.sub(r"^---.*?---", "", open(f).read(), flags=re.S) # front matter
text = re.sub(r"```.*?```", "", text, flags=re.S) # code blocks
text = re.sub(r"\{/\*.*?\*/\}", "", text, flags=re.S) # MDX comments
for n, line in enumerate(text.split("\n"), 1):
prose = re.sub(r"`[^`]*`", "", line) # inline code
prose = re.sub(r"<[A-Za-z][^>]*>", "", prose) # tags and their attributes
prose = re.sub(r"\\[{}]", "", prose) # escaped braces are fine
if "{" in prose or "}" in prose:
hits += 1
print(f"{f}: {line.strip()[:90]}")
print(f"{hits} line(s) with braces outside code")
sys.exit(1 if hits else 0)
It removes everything where braces are expected or safe (front matter, code blocks, MDX comments like {/* note */}, inline code, tag attributes, escaped braces), then reports any braces left. It exits with 1 when it finds some, so it can fail a CI check.
(Your machine)
python3 find-braces.py content
content/news/cookie-deal.mdx: Rye cookies are < $3 this week, and every box comes with a {free} sticker.
1 line(s) with braces outside code
After changing {free} to \{free\}, the same command reported 0 line(s) with braces outside code, and the page showed "with a {free} sticker".
In real systems
- This happens on real sites. On one site built exactly this way, the scanner found three published pages with words missing: a sentence that read
"Dear , your order ships on ."where a mail-merge example had been, a quiz answer missing its key word, and a code example with its variable removed. Nobody had noticed. - Pin the library's version and read its changelog. next-mdx-remote 6 changed the default to block JavaScript; sites that upgraded found expressions they relied on gone.
- Content can come from anywhere. Files in Git, a content management system, a database. The trust question is the same: who can edit it, and what can their edits do?
- Review MDX like code. A pull request that changes a post can change what runs at build time. On sites that compile MDX with expressions on, treat content edits with the same care as code.
- Drafts in Git are still public if the repository is.
status: drafthides a post from the site, not from anyone who can read the repo.
Spot the problem
Work out what's wrong before you read the explanation.
"Customers can write their own reviews in Markdown." A bakery adds reviews to its product pages. To let customers use bold and links, a developer stores each review as text in the database and renders it with the same MDXRemote setup the news posts use, which has blockJS: false, blockDangerousJS: false because the news posts use expressions. A week later, the site's environment variables, including the payment provider's API key, are posted on a forum. A review contained an expression that read them. What's wrong? MDX from the public was compiled with code execution on. The review wasn't text: it was a program, and the bakery ran it on its own server. Three fixes: never compile untrusted input as MDX; render reviews as plain text, or with a Markdown library that doesn't run code, and sanitize the result (Lesson 3); keep blockDangerousJS on everywhere, and give writers components instead of expressions; and rotate the leaked key at once, then check what else the server could reach.
Keep this
| Need | Do |
|---|---|
| Title, date, status | Front matter, split with gray-matter |
| A page per post | generateStaticParams from the file list |
| Rich content for writers | Components passed to MDXRemote, not expressions |
| Drafts | A status field, shown only outside production |
| A literal brace | { and }, or code formatting |
| A literal less-than | <, a space after it, or code formatting |
| Safety | Keep blockJS and blockDangerousJS on; trusted authors only |
| Catch silent loss | Scan for unescaped braces in CI |
Where you are now
You can write posts in MDX with front matter, load and list them, turn each into a static page, give writers components, and keep drafts out of production. You also know MDX is code: what braces and angle brackets do, which settings keep it from running, and how to catch the words it deletes quietly. The next lesson replaces the bakery's menu file with a real API, and deals with what happens when that API is slow, down, or wrong.
Check yourself
What does gray-matter do with an MDX file?
Why was holiday-orders a 404 in production but not in development?
What happened to {free} in the cookie post?
Why did 'We <3 rye' break the build?
What did turning off both safety switches allow?
How should the bakery let customers format their reviews?
Key terms
- Markdown: a simple way to write formatted text with plain characters.
- MDX: Markdown that can use components, compiled into JavaScript.
- Front matter: YAML at the top of a file, between
---lines, holding data about it. - gray-matter: a library that splits front matter from a file's body.
- next-mdx-remote: a library that compiles MDX from any source into React, on the server.
- Expression: JavaScript inside
{ }in MDX or JSX. blockJSandblockDangerousJS: next-mdx-remote 6's switches for removing expressions, and for blocking dangerous ones when expressions are allowed.- Escaping: writing a character so it's shown instead of interpreted, like
\{in MDX. - Draft: content kept out of the published site until it's ready.
Go deeper
- MDX: what is MDX? (opens in a new tab) and troubleshooting MDX (opens in a new tab).
- next-mdx-remote on GitHub (opens in a new tab), including its security section.
- Next.js: MDX in Next.js (opens in a new tab).
- Try: add a
tagslist to each post's front matter, and a page at/news/tag/[tag]that lists posts with that tag. - Try: run the brace scanner on any MDX site you work on. How many silent deletions does it find?