JTBack to home
6 min read

A blog without MDX

I built this blog with plain TSX pages and a typed registry instead of MDX. Zero bundler configuration, full type safety, and file-convention metadata and Open Graph images per post.

When you add a blog to a Next.js site, the default answer is MDX. It is what the tutorials reach for, and for good reason: Markdown with components sprinkled in is a comfortable format for writing. I looked at it, weighed it against what this portfolio actually needed, and left it out. The posts here are plain TSX pages backed by a typed registry. Here is why, and what that buys and costs.

The default answer I skipped

This site runs on Next.js 16, which builds with Turbopack. Wiring @next/mdx into that pipeline is extra configuration and one more moving part in the bundler, for what amounts to a handful of posts. The risk is small but it is not zero, and it is permanent: every future upgrade has to keep that integration working.

So I asked the question worth asking before adding any dependency. What does MDX actually buy you if the person writing the posts is an engineer who is comfortable in JSX anyway? MDX exists to let Markdown and components live together, mostly so that non-engineers can write prose without touching code. I am the only author, and JSX is already my native format. The main benefit did not apply to me.

What a post actually is

Each post is a real folder with a plain page component at src/app/blog/<slug>/page.tsx. There is no dynamic [slug] route and no content loader. The route exists because the folder exists, which is the App Router working exactly as designed. Every post wraps its body in a shared PostLayout that owns the header, the prose container and the footer, so the pages themselves are just content.

The single source of truth is a typed registry in src/lib/posts.ts. Each entry is one object, and the type is deliberately small:

export type Post = {
  /** URL slug, matches the folder under src/app/blog/<slug>. */
  slug: string;
  title: string;
  description: string;
  /** ISO date string, e.g. "2026-07-02". */
  date: string;
  /** Human-readable reading time, e.g. "8 min read". */
  readingTime: string;
};

That array feeds three surfaces from one place. The /blog listing maps over it to render the index. sitemap.ts maps over it to emit a URL entry per post. The RSS feed at /feed.xml maps over it to build the channel items. Add one object and all three update together, because they read the same list. Nothing can drift out of sync because there is only one list to read.

Two steps to add a post

Publishing is mechanical. Create the page at the slug folder, then add one entry to the registry. That is the whole ritual. The page renders the words, the registry entry makes the post show up in the index, the sitemap and the feed. Because the registry is typed, a missing field is a compile error rather than a blank space discovered later in production.

What file conventions hand you for free

Skipping MDX did not mean giving up the nice per-post surfaces. The App Router file conventions provide them directly. Every post exports its own metadata object, so each one gets a canonical URL, an Open Graph article card and a Twitter card without any shared plumbing:

export const metadata: Metadata = {
  title: post.title,
  description,
  alternates: { canonical: `/blog/${post.slug}` },
  openGraph: {
    type: "article",
    url: `/blog/${post.slug}`,
    title: `${post.title} - Julien Tavernier`,
    description,
  },
  twitter: { card: "summary_large_image", /* ... */ },
};

Social images are the same story. Each post has an opengraph-image.tsx beside its page. It reads the matching entry from the registry and hands it to one shared template rendered with next/og, so every card is generated at request time from real post data. There is no static image to export and no design file to keep in step with the title.

Styling the prose

The reading experience comes from @tailwindcss/typography, loaded with a single line in globals.css:

@plugin "@tailwindcss/typography";

The layout then tunes it with prose-* modifiers so the article matches the rest of the dark theme. A couple of examples: prose-invert flips the palette for a dark background, prose-a:text-violet paints links in the brand accent, and prose-pre:bg-card gives code blocks the same surface as the cards elsewhere on the site. It is a small block of utility classes in one place, and it styles every post at once.

The honest tradeoffs

This approach is not free of friction, and it would be dishonest to pretend otherwise. Writing prose as TSX means the words share a file with JSX. Apostrophes need escaping, and long passages carry the visual noise of tags and braces that Markdown would spare you. There is also no remark or rehype ecosystem in the pipeline, so there are no automatic heading anchors, no footnote plugins, and none of the Markdown shortcuts that make heavy writing pleasant. For someone publishing every day, that overhead would add up fast, and MDX would clearly win.

The other side of the ledger is real too. There is zero bundler configuration to maintain, so upgrades are one less thing to worry about. The registry gives full type safety from the data all the way to the rendered card. Components drop into a post anywhere with no special wiring, because a post is already a component. And the entire pipeline, from the listing to the sitemap to the feed, is plain code you can open and read top to bottom, with no compile step or content abstraction in between.

When I would still pick MDX

None of this makes MDX a mistake. The moment the calculus changes is when writing volume goes up or the authors change. If I were publishing frequently, or handing the blog to writers who should never see a semicolon, MDX would be the right call. Its whole point is to let prose be prose while still allowing components, and that value scales with the number of posts and the number of people writing them. My blog has neither of those pressures, so the cost of MDX outweighs its benefit here. On a different project it could easily be the other way around.

Takeaways

What I would carry to the next small content site:

  • Ask what a dependency actually buys you for your situation, not in general. MDX solves a problem I did not have.
  • A typed registry as the single source of truth keeps the listing, sitemap and feed from ever drifting apart.
  • File conventions give you per-post metadata and generated Open Graph images without a content framework.
  • The typography plugin plus a few prose-* modifiers is enough styling for a whole blog.
  • For low-volume, single-author writing, plain TSX wins on simplicity. For high-volume or non-engineer authors, reach for MDX.

The best tool is the one that matches the shape of the problem. For a few posts written by one engineer, plain pages and a typed list were the smaller, sturdier choice.