Web development basics · Lesson 5 of 11 · about 30 minutes

Content from MDX files

The bakery gets a news section written in MDX: Markdown that can use your own components, with front matter for titles, dates and drafts. Each post becomes a static page at build time, and drafts stay out of production. Then the part most tutorials skip: MDX is code, not text. A brace in a sentence silently deleted a word, a heart emoji made of a less-than sign broke the build, and with the library's safety switches off, a post printed a secret key onto the page.

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 rye failed 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:

TEXT
content/news/
├── rye-week.mdx         published
├── new-oven.mdx         published
└── holiday-orders.mdx   draft

Here's rye-week.mdx:

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: value pairs about the post, not part of its text.
  • status is the bakery's own convention: published or draft.
  • <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:

BASH
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:

JS
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.readdirSync lists the files in content/news; this code runs on the server (or at build time), never in the browser.
  • matter(...) returns data (the front matter as an object) and content (the body).
  • The slug is the file name without .mdx: rye-week.mdx becomes /news/rye-week.
  • SHOW_DRAFTS is true in development and false in production builds. next dev sets NODE_ENV to development; next build sets it to production.
  • Sorting by date works as text because the dates are written YYYY-MM-DD.
  • The slug check in getPost allows only lowercase letters, digits and dashes, so a URL like /news/..%2F..%2Fsecrets can't read a file outside the folder.

Components for writers

JS
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

JS
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)

BASH
npm run build

The end of the build:

TEXT
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.

How a post becomes a page, and what happens to braces. Select the diagram to enlarge it.

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):

TEXT
== 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.00 from the menu data, and ## What's new became 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:

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.:

TEXT
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:

  • blockJS removes JavaScript expressions entirely. That's what deleted {free}.
  • blockDangerousJS, if blockJS is turned off, makes a best-effort attempt to block dangerous things like process, eval and require.

Someone will eventually want {2 * 2.5} to work, and turn the first switch off. To see what that means, the post became:

MDX
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:

TEXT
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 caught process and 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 &lt; or put a space after < when you mean "less than". In a code span, it's fine as is.
  • Leave blockJS on unless you need expressions, and leave blockDangerousJS on 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:

PYTHON
#!/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)

BASH
python3 find-braces.py content
TEXT
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: draft hides 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

NeedDo
Title, date, statusFront matter, split with gray-matter
A page per postgenerateStaticParams from the file list
Rich content for writersComponents passed to MDXRemote, not expressions
DraftsA status field, shown only outside production
A literal brace{ and }, or code formatting
A literal less-than&lt;, a space after it, or code formatting
SafetyKeep blockJS and blockDangerousJS on; trusted authors only
Catch silent lossScan 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

  1. What does gray-matter do with an MDX file?

  2. Why was holiday-orders a 404 in production but not in development?

  3. What happened to {free} in the cookie post?

  4. Why did 'We <3 rye' break the build?

  5. What did turning off both safety switches allow?

  6. 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.
  • blockJS and blockDangerousJS: 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