Enriching the Blog Experience

|7 min read|

Adding custom MDX components to make posts more expressive without breaking the site's design.

Plain markdown is fine for most things. Paragraphs, headings, lists, code blocks: that covers the majority of what you need for a technical post. But there's a ceiling. After a while you start noticing that everything looks the same. Every post is just a wall of text broken up by the occasional code snippet, and there's no visual hierarchy beyond h2s and h3s.

I wanted two things: a richer reading experience, and a nicer writing experience. The reading side is obvious. More visual variety keeps people engaged. The writing side is more selfish. I write better when I have tools that let me express emphasis properly. Shoving everything into a blockquote or a bold sentence feels like a hack.

So I added three custom MDX components: Callout, PullQuote, and ImageCaption.

What MDX Actually Gives You

Before getting into the components, a quick note on how this works. My blog posts are .mdx files, which is Markdown with JSX support baked in. Anywhere you'd write regular markdown, you can also drop in a React component. So a post can look like this:

## Some heading
 
Regular paragraph text here.
 
<Callout type="tip">
  This is a tip rendered as a React component.
</Callout>
 
More paragraph text.
mdx

The key part is how those components get registered. In Next.js with next-mdx-remote, you pass a components map to the MDXRemote renderer:

app/blog/[slug]/page.tsx
<MDXRemote
  source={post.content}
  components={{ pre: CodeBlock, Callout, PullQuote, ImageCaption }}
  options={{ ... }}
/>
tsx

That's it. Once a component is in that map, it's available in every post without any imports. You just use it by name. This is what makes the writing experience actually pleasant: no remembering to import anything, just write <Callout> and it works.

Callout

The most useful of the three. Callouts are highlighted boxes for surfacing context worth separating from the prose: warnings, notes, tips, background information.

Note

This is a note callout, good for gentle caveats or extra context that doesn't belong in the main flow.

Tip

This is a tip callout. Use it for actionable suggestions.

Warning

This is a warning callout. Use it when something can go wrong.

Custom title

This is an info callout with a custom title overriding the default label.

There are four variants: note, tip, warning, and info. The way this is implemented is with a config object at the top of the component file:

app/components/mdx/Callout.tsx
const config: Record<
  CalloutType,
  { label: string; icon: string; bg: string; border: string; labelColor: string }
> = {
  note: {
    label: "Note",
    icon: "✎",
    bg: "var(--color-surface)",
    border: "var(--color-border)",
    labelColor: "var(--color-muted)",
  },
  tip: {
    label: "Tip",
    icon: "✦",
    bg: "#f3faf3",
    border: "#5a9e5a",
    labelColor: "#3d7a3d",
  },
  // ...
};
tsx

Rather than writing a big if/else or switch inside the render function, the config map keeps all the variant data in one place. Adding a new variant is just adding a new key. The component itself then just does:

app/components/mdx/Callout.tsx
const { label, icon, bg, border, labelColor } = config[type];
tsx

And uses those values inline. Clean enough.

The note variant deliberately uses --color-surface and --color-border from the site's CSS variables so it blends naturally with the warm background. The other three variants (tip, warning, info) step outside the palette slightly, into green, amber, and blue, because they need to communicate urgency or category at a glance. If all four variants used the same brown tones they'd be indistinguishable. So the rule I followed: note stays within the site palette, the other three get their own colours.

The left-side border is always 3px and the component uses borderRadius: "0 6px 6px 0", flat on the left (flush with the border) and rounded on the right. It's a small detail but it makes the shape feel intentional rather than just a box.

You can also pass a custom title prop to override the default label:

<Callout type="warning" title="Heads up">
  This will override "Warning" with "Heads up".
</Callout>
mdx

The title falls back to the config label if you leave it out, so title is entirely optional.

PullQuote

Pull quotes are common in editorial design. A sentence gets pulled from the article and displayed larger, giving the reader something to pause on while skimming. They work well for one or two lines you want to emphasise without interrupting the paragraph flow.

There's a ceiling to what plain markdown can express, and I kept hitting it.

The implementation is straightforward:

app/components/mdx/PullQuote.tsx
<figure style={{ margin: "2.5rem 0", padding: "0 1rem", textAlign: "center" }}>
  <span aria-hidden="true" style={{
    display: "block",
    fontSize: "3rem",
    lineHeight: 1,
    color: "var(--color-border)",
    fontFamily: "var(--font-eb-garamond), serif",
    marginBottom: "-0.5rem",
  }}>
    &ldquo;
  </span>
  <blockquote style={{
    fontFamily: "var(--font-eb-garamond), serif",
    fontSize: "1.45rem",
    lineHeight: 1.55,
    color: "var(--color-accent)",
    fontStyle: "italic",
    margin: "0", padding: "0", border: "none",
  }}>
    {children}
  </blockquote>
</figure>
tsx

The font choice matters here. The rest of the site uses Circular Std for body text: clean, geometric, modern. Post titles use EB Garamond. Using EB Garamond for the pull quote text makes it feel like it belongs to the same register as the title, elevated and slightly more considered. It signals to the reader that this particular sentence is worth slowing down for.

The opening quote glyph is set to --color-border, a light warm tan, visible but not competing with the quote text itself. Setting aria-hidden="true" on that span means screen readers skip it, since the <blockquote> element already communicates the semantic meaning.

The negative marginBottom: "-0.5rem" on the glyph pulls the quote text up slightly so there's no awkward gap between the decorative mark and the words below it.

You can also attribute a quote to someone with the optional author prop:

<PullQuote author="Paul Graham">
  Write simply. Don't use a complicated word where a simple word will do.
</PullQuote>
mdx

Write simply. Don't use a complicated word where a simple word will do.

Paul Graham

The attribution renders in small-caps Circular Std, consistent with how dates and labels are styled elsewhere on the site.

ImageCaption

This one is the simplest. Standard markdown images (![alt](src)) render fine, but they give you no way to add a caption below them. ImageCaption wraps an <img> in a <figure> with an optional <figcaption>:

app/components/mdx/ImageCaption.tsx
<figure style={{ margin: "2rem 0", textAlign: "center" }}>
  <img
    src={src}
    alt={alt}
    style={{
      display: "inline-block",
      maxWidth: "100%",
      borderRadius: "6px",
      border: "1px solid var(--color-border)",
    }}
  />
  {caption && (
    <figcaption style={{
      marginTop: "0.6rem",
      fontSize: "0.75rem",
      color: "var(--color-muted)",
      fontStyle: "italic",
    }}>
      {caption}
    </figcaption>
  )}
</figure>
tsx

One deliberate choice here: I'm using a regular <img> tag instead of Next.js's <Image> component. Next.js Image requires explicit width and height props to do its layout work, or a fill mode that needs a sized container. In an MDX context where images can be anything (screenshots, diagrams, photos), enforcing those props would make writing more annoying. The eslint-disable comment suppresses the lint warning since this is intentional.

The image gets a 1px solid var(--color-border) border, the same border colour used on code blocks, cards, and dividers throughout the site. It gives images a framed, deliberate feel rather than just floating them raw in the prose.

Usage in a post looks like this:

<ImageCaption
  src="/images/architecture-diagram.png"
  alt="Three-stage data flow diagram"
  caption="Figure 1: data flows left to right through ingest, transform, and serve."
/>
mdx

Caption is optional. If you leave it out, you get a styled image with no text below it, which is fine for decorative images.

Design Consistency

The thing that ties all three components together is CSS variables. The site's palette is defined in globals.css:

app/globals.css
:root {
  --color-bg:       #fffdfa;
  --color-surface:  #f5ede0;
  --color-border:   #e2cdb0;
  --color-text:     #2a1a08;
  --color-muted:    #936a25;
  --color-secondary:#674a1a;
  --color-accent:   #7a5018;
}
css

Every component references these variables rather than hardcoded hex values (with the exception of the tip/warning/info callout colours, which need to step outside the warm palette). This means if I ever update the palette, the components update for free.

The warm background (#fffdfa) is slightly off-white with a very faint warm cast. The surface (#f5ede0) is the next step up, used for code blocks, the note callout, and hover states. There's a natural visual hierarchy between the two that the components can lean into without any extra work.

How to Use Them

All three components are available in every post. No imports, just use them:

<Callout type="tip" title="My title">
  Markdown **works** inside callouts.
</Callout>
 
<PullQuote author="Optional attribution">
  A sentence worth pausing on.
</PullQuote>
 
<ImageCaption
  src="/images/foo.png"
  alt="description"
  caption="Optional caption text."
/>
mdx

That's the whole writing experience improvement. Writing a post now has a bit more range: I can draw attention to something important without bolding half a paragraph, give a key idea room to breathe with a pull quote, and caption a diagram properly. Not revolutionary, but noticeably better than what I had before.