← cd /blog

Article

next-mdx-remote 6 Deletes Braces From Your Prose

·
buildsnext.jsmeta

On this site, Value is {2+2} here. in a post renders as "Value is here." and the build passes. That is one of five failures in this Next.js 16 MDX blog on Vercel that raised no error. Each has a small fix:

  • Braces in prose vanish: wrap them in inline code.
  • Tables render as raw pipes: use a list.
  • A failed Resend send returns 200: read error from the send result.
  • A deleted Vercel env var stays live in the running deployment: revoke the key at the issuer.
  • The CSP blocks Umami's beacon: allow gateway.umami.is in connect-src.

Versions

The dependencies, at the versions the lockfile resolves:

  • next 16.3.0, App Router, static generation
  • next-mdx-remote 6.0.0, rendering through next-mdx-remote/rsc
  • gray-matter 4.0.3 for frontmatter
  • Tailwind CSS 4.1.18, configured in CSS with @theme inline
  • resend 6.19.0 for the contact form
  • Umami Cloud for analytics

The commands, from the repo's CLAUDE.md:

npm run build          # Build (always run before deploy)
npm run dev            # Dev server
npx vercel --prod --yes # Deploy to production

Vercel builds a push to main in about 60 seconds.

next-mdx-remote 6 strips {expressions} from prose

The post page hands the source straight to the renderer, with no options:

<MDXRemote source={post.content} />

{anything} in MDX prose is a JavaScript expression. next-mdx-remote 6 removes expressions unless told otherwise, because blockJS defaults to true in next-mdx-remote/dist/serialize.js, line 36:

export async function serialize(source, { scope = {}, mdxOptions = {}, parseFrontmatter = false, blockJS = true, blockDangerousJS = true, } = {}, rsc = false) {

MDXRemote from /rsc takes that default when you pass no options. The braces and everything between them disappear from the page, and the build passes. Wrap braces in inline code.

MDX here runs with no plugins

There is no remark-gfm. With @mdx-js/mdx 3.1.1, that means:

  • Tables compile, then render as raw pipe characters. Three published posts carried broken tables until October 2026. Use a list.
  • <Word> in prose is read as a JSX tag and breaks the build. So does an HTML comment. Backticks fix the first, {/* */} replaces the second.
  • Strikethrough, footnotes, task lists and bare URLs do not render.

remark-gfm would fix tables. It would also change how ~ and bare URLs parse in every existing post.

A post is a file in content/

The filename is the slug. Frontmatter has four keys:

---
title: "Post Title"
date: "2026-02-13"
description: "SEO description under 160 chars."
tags: ["builds", "meta"]
---

getAllPosts() in src/lib/mdx.ts reads every .mdx file, parses it with gray-matter and sorts newest first:

export function getAllPosts(): PostMeta[] {
  // ...
  const files = fs.readdirSync(contentDir).filter((f) => f.endsWith(".mdx"));
  // ...
  return posts.sort(
    (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
  );
}

The post route builds one page per entry at build time:

export async function generateStaticParams() {
  const posts = getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

There is no CMS and no database.

Every .mdx file in content/ is a post, the glossary reference page included. It first shipped with a fake date, 1993-04-07, to sort it to the bottom of the blog list. That date leaked into the sitemap, the RSS feed and the JSON-LD. It now has a real date, and the homepage filters it out by slug:

const latestPosts = getAllPosts()
  .filter((post) => post.slug !== "glossary")
  .slice(0, 5);

The colophon and the word count filter it too. The /blog index, the feed and the sitemap do not, so the glossary appears in all three.

Glossary tooltips are added in the browser

GlossaryHighlighter, a client component around the rendered MDX, walks the text nodes after render. It wraps the first occurrence of each term in src/data/glossary.ts (60 entries) in a tooltip span. It skips a text node whose parent is one of these:

const tag = parent.tagName.toLowerCase();
if (
  tag === "code" ||
  tag === "pre" ||
  tag === "script" ||
  tag === "style" ||
  tag === "a" ||
  parent.classList.contains("glossary-term")
) {
  return NodeFilter.FILTER_REJECT;
}

A term written in backticks or as plain link text never gets a tooltip. An August proposal to move the walk to build time was rejected on measurement: the walk takes 0.026 ms per run.

Dark-only means shipping your own 404

The root layout sets <html lang="en" className="dark">. The palette is defined once in :root and mapped into Tailwind with @theme inline in globals.css:

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  /* ... */
  --color-accent: var(--accent);
  /* ... */
}

:root {
  --background: #0f0e0e;
  --foreground: #C5C9C5;
  /* ... */
  --accent: #5bb8b8;
  /* ... */
}

Dropping next-themes and its toggle in February deleted the dependency and two components, ThemeProvider and ThemeToggle.

Next 16.3.0's built-in 404 injects its own CSS, from next/dist/client/components/http-access-fallback/error-fallback.js, line 37:

body{color:#000;background:#fff;margin:0}
/* ... */
@media (prefers-color-scheme:dark){body{color:#fff;background:#000}/* ... */}

That CSS beats the site's, so the default 404 is white, or pure black for a visitor in dark mode. Ship your own src/app/not-found.tsx.

Social cards need font files, not Google Fonts

src/app/blog/[slug]/opengraph-image.tsx renders a 1200 by 630 PNG per post at build. ImageResponse cannot use Google Fonts CSS, so three OFL font files are committed under src/assets/fonts and read from disk:

const [playfair, grotesk, mono] = await Promise.all([
  readFile(path.join(FONT_DIR, "playfair-display-700.ttf")),
  readFile(path.join(FONT_DIR, "space-grotesk-400.ttf")),
  readFile(path.join(FONT_DIR, "geist-mono-400.ttf")),
]);

To get static TTF links, request the Google Fonts css2 API with a non-browser user agent:

curl -A curl 'fonts.googleapis.com/css2?family=...'

The opengraph-image.tsx convention emits both og:image and twitter:image, so skip a separate twitter-image file.

Resend reports a failed send in its return value

src/app/api/contact/route.ts is the one route marked force-dynamic. It creates the Resend client inside the handler, not at module level. The SDK reports errors two ways. The constructor throws when there is no API key. A failed send resolves with { error } and does not throw.

Until August, the route awaited the send inside try/catch and returned success:

await resend.emails.send({
  // ...
});

return NextResponse.json({ success: true }, { headers: cors });

A dead key or a rejected sender came back as 200. The route now reads the result:

const resend = new Resend(process.env.RESEND_API_KEY);

const { error } = await resend.emails.send({
  // ...
});

if (error) {
  console.error("Contact form: Resend rejected send:", error);
  return NextResponse.json(
    { error: "Something went wrong. Try again later." },
    { status: 500, headers: cors }
  );
}

The console.error lands in the Vercel function logs.

Note: On an unverified domain, Resend's sandbox only delivers to the account owner's own address, from onboarding@resend.dev. To test with any other recipient, verify the sending domain first.

Vercel env vars belong to a deployment

In April, RESEND_API_KEY was removed from the project settings. The running deployment kept its copy and sent mail until the key stopped working at Resend's end. The old route discarded the send result, so the form kept answering 200. The new key, added in August, needed a redeploy before the form could see it.

Settings changes reach new deployments only. Removing a variable does not revoke a key. Revoke it at the issuer.

Allow Umami's beacon host in the CSP

Umami's script posts its beacon to gateway.umami.is, which this site's connect-src blocked until 12 August. The analytics post has the fix and the check.

Check the repo accepts pushes before debugging Vercel

The repo was archived from April to 12 August 2026. Pushes failed with "This repository was archived", so production stayed on the 14 March build. It was first misdiagnosed as a disconnected Vercel integration. If production looks stale, check that the repo accepts pushes before touching Vercel.