In defence of over-engineering your blog

I've rebuilt this blog with the same design almost half a dozen times. Here's what I learned doing it again.

View in Sanity Studio


I'm aware almost no one will actually read this. That doesn't matter.

Your own blog is the perfect, low-risk place to break, test and learn new things. This iteration has been instructive for:

  • Embedding Sanity Studio v3 into an app
  • Better TypeScript support and runtime validation from backend to frontend
  • Fixing some things that were busted when I first built this on an early version of Remix, like the dark/light mode toggle
  • Modernising how Tailwind's Typography classes were integrated with Portable Text
  • Faster deployments with a change of host
  • New, dynamic social share images

...and everything I've learned in this side-project has been reflected back into what I'm doing day to day for my actual job. No wasted time. Every day's a school day.

What hasn't changed

This blog still runs on Remix. It's still my favourite framework for building websites and apps.

It's also still styled with Tailwind CSS. I can't imagine a future where that's not the case. There's nothing I want from CSS that Tailwind doesn't give me. But perhaps I just don't know what I don't know.

And (obviously) it's still serving structured content from Sanity, but there's something new and special about it now...

What's changed

Embedded Sanity Studio

You can now browse the Sanity Studio that I use to author content for this website!

This has no actual functional purpose (for now) it's more of a "because it's possible" curiosity. Click the "Open in Sanity Studio" link beneath the heading and click around the site.

It's very cool to have the editing surface and front-end contexts back together (it's WordPress all over again!) but also still maintaining a truly headless separation of data and content.

Zod for validation and type safety

Since using Remix I've been writing TypeScript (admittedly, not well) but what bugged me was that you can write Types for incoming data – and they're immediately out of date the moment your schema changes or you add new data to your query.

I don't understand what good type hints for data if TypeScript is unaware of the actual value of that data.

So now I'm querying Sanity data and immediately parsing it with Zod to both check for run-time correctness and generate Types.

// Simplified query from this blog with
// Zod validating and creating types for the result
const articles = await client
.fetch(articleQuery, params)
.then((result) => articlesZ.parse(result))

I wrote more about this here:

More on Sanity x GROQ x Zod

vercel/og for meta images

I've written guides before on using your own website to create meta images as dedicated routes, and then using a screenshot API to turn those into meta images.

It works because you can use dynamic content and your website's own style to create up to date and on-brand meta images.

However, screenshot APIs cost money which I was reluctantly spending.

Vercel recently launched vercel/og-image to dynamically generate open graph images within their edge runtime, and they work great!

Now I have a separate app running in Vercel just to create these images, and since they're dynamic I can load a live preview of them while editing.

Sanity Studio CMS showing editing pane alongside open graph image

The syntax to generate the layouts in these images is a bit funky but it works. Want to see for yourself? Share this post on Twitter!

Hosted on Vercel

The previous version was hosted on – a service which I like not only because it's technically very interesting but because of how much they appear value design (amazing blog post graphics) and rapid iteration (every time I logged into the dashboard there was something new).

However, deploys were slow and "heavy". Watching a docker file build just to launch a blog felt like too much (yes even in the context of this blog post).

I was deploying on Vercel during development and it was working so well, I just switched over to production there.

Note: deploys don't happen often with a Remix app once it is stable. I never "redeploy" or "rebuild" for a content change, only code changes.

Tailwind CSS Typography, Portable Text and not-prose

Tailwind's Typography plugin solves the problem of having to style rich text – like Portable Text. I don't want to think these formatting rules, and I trust Tailwind's designers defaults.

But, the cascading styles used to be a headache when you load custom types from Portable Text, like videos and buttons. Because you'd need to "escape" out of prose styling. I've written about this before and advised a .reduce() to separate these custom types away from normally formatted blocks of text.

No more! Now you can choose within each component supplied to <PortableText /> to escape the inherited styles by wrapping it in an element with a not-prose class.

// Very meta example from TypeCode.tsx
// The component for this type in my PortableText
<div className="not-prose">
<Prism code={value.code} language={value.language} />

An even more clever rendering for Table of Contents

I'm very proud of this. I probably should write a guide. But in short...

It starts with this GROQ query:

"tableOfContents": content[style in ["h2", "h3"]]

This will return every block from my Portable Text for the content of the page that is a h2 or h3 heading block.

Next, I load the value of this into a Portable Text component, but with a special set of components just for these headings

<PortableText value={value} components={tocComponents} />

The renderer for these h2 and h3 blocks actually return's <li> elements with anchor links. The links are generated from the unique _key value that every item in an array receives.

Sort of like this:

<a href={`#${props.value._key}`}>{props.children}</a>

Switched to the variable version of the Inter font face

Not that I'm taking advantage of it, it just seemed like the modern thing to do...