I Built My Blog Twice: From React SPA to Astro, Rust Workers, D1 and R2

14 min read · 2995 wordsFront End
I Built My Blog Twice: From React SPA to Astro, Rust Workers, D1 and R2 cover

I built my personal blog twice: first with React, Vite, Rust/WASM Workers, D1 and Keycloak, then rebuilt it with Astro SSG, D1, R2 and Cloudflare Access.

I Built My Blog Twice: From React SPA to Astro, Rust Workers, D1 and R2

Last October, I built myself a blog.

Then I barely wrote anything on it.

Almost a year later, I came back, changed the database schema, made the original first post incompatible with the new system, and rebuilt most of the architecture anyway.

So instead of trying to resurrect that first post from its Markdown backup, I figured I might as well write a new one.

This is the story of how I built the same blog twice.

The first version used React + Vite + Rust/WASM Workers + D1 + Keycloak.

The current version uses Astro SSG + React islands + Rust Workers + D1 + R2 + Cloudflare Access.

The first version worked.

The second version is actually a blog.

Why did I build my own blog in the first place?

I've always liked personal websites built by other developers.

Obviously, there are probably a hundred existing solutions that can give me a perfectly functional blog in five minutes. I could install WordPress, use a hosted platform, pick a template, and then spend my time doing the thing people normally do after creating a blog:

writing blog posts.

Instead, I decided to build the blogging platform itself.

My logic was pretty simple:

If I'm going to build something myself instead of using an existing solution, there should at least be something interesting about the result.

I didn't want another server that I had to maintain just to serve a few articles, either.

A traditional setup with a VM, database, and object storage would cost actual money every month. For a personal blog that might receive a heroic number of twelve visitors on a good day, that seemed unnecessary.

Cloudflare's serverless stack looked much more appropriate.

Workers could run the backend, D1 could hold the data, and the whole thing would probably stay inside the free tier for approximately the rest of my natural life.

That part made sense.

Then I chose Rust.

Why Rust and WASM?

The TL;DR is that I hate myself.

The longer explanation is slightly more reasonable.

My first experience with Cloudflare Workers came from a budget-constrained project for a nonprofit. At the time, I didn't really understand how generous the Workers free tier was, so I somehow became worried that using TypeScript might make the project exceed it.

It almost certainly wouldn't have.

But that project pushed me toward Rust and WebAssembly on Workers, and the habit stuck.

By the time I built the first version of this blog, using Rust for Cloudflare Workers already felt normal to me.

So the backend became a Rust cdylib compiled to WASM, with an axum-style router.

Was this necessary for a personal blog?

No.

Was it fun?

Most of the time.

Version 1: React, Vite, D1 and Keycloak

The first architecture looked roughly like this:

Browser
   │
   ▼
React + Vite SPA
   │
   ▼
Cloudflare Worker
   │
   ├── D1
   │    ├── posts
   │    ├── tags
   │    └── post_tags
   │
   └── Keycloak OIDC
        └── my homelab

The frontend was a normal React SPA built with Vite.

The Worker exposed the API.

D1 stored the blog data.

Keycloak handled authentication for the admin interface.

And because my Keycloak instance was already running in my homelab, I thought:

Well, it's already there. Why not use it?

This sentence has caused a surprising amount of work in my life.

Making relational SQLite pretend to be document storage

One of the first problems was tags.

Originally, I wanted something that felt closer to document storage. But since the entire point of the architecture was to stay serverless, I chose D1 instead.

That meant I ended up with the classic relational setup:

CREATE TABLE posts (
    _id INTEGER PRIMARY KEY,
    title TEXT,
    excerpt TEXT,
    date TEXT,
    category TEXT,
    cover_image TEXT,
    content TEXT,
    word_count INTEGER,
    read_time INTEGER
);

CREATE TABLE tags (
    _id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT UNIQUE,
    color_class TEXT
);

CREATE TABLE post_tags (
    post_id TEXT,
    tag_id INTEGER,
    FOREIGN KEY(post_id) REFERENCES posts(_id),
    FOREIGN KEY(tag_id) REFERENCES tags(_id)
);

This worked.

It also meant I was manually dealing with SQL, foreign keys, tags, and consistency from Rust on Cloudflare Workers.

It wasn't necessarily difficult.

It just created many opportunities to ask myself why I wasn't using an existing blogging platform like a normal person.

And then there was OIDC

OIDC is one of those things that sounds extremely simple when you explain it at a whiteboard.

The user logs in.

The identity provider gives you something.

You verify the something.

Congratulations, authentication.

Then you actually implement it.

I had already implemented variations of this flow before, including a Discord bot with a Gin backend and another project involving Flutter and Cloudflare Workers.

For the React version of the blog, I used keycloak-js, created an AuthProvider and AuthContext, and reused parts of what I had already learned.

It mostly worked.

Except, among other things, I learned that Keycloak's aud claim does not magically become whatever I thought the client name should be.

After several rounds of debugging, I fixed the audience by adding a mapper to the client scope.

Problem solved.

Surely authentication would never hurt me again.

This statement will become important later.

The problem I somehow forgot: this is supposed to be a blog

Technically, Version 1 was successful.

I could create posts.

I could edit them.

I had authentication.

I had tags.

I had a Rust backend running at the edge.

It was cool.

There was only one minor issue.

The SEO was terrible.

The frontend was a React SPA.

When a crawler requested an article, the initial HTML was essentially an empty application shell containing a #root element. JavaScript then had to run, call the API, fetch the article, and render it.

That's perfectly reasonable for many web applications.

A blog is not one of the places where I particularly want to make a crawler work for the content.

I had successfully engineered a fairly sophisticated system for publishing text while making the text itself unnecessarily difficult to see.

And then, demonstrating my commitment to content creation, I stopped writing new posts for almost a year.

Excellent.

Version 2: maybe the HTML should contain the article

When I finally returned to the project, the biggest architectural decision was straightforward:

The public blog should be static.

I replaced the React SPA with Astro, configured for static generation.

React didn't completely disappear. The admin interface still uses React as an island, because an interactive Markdown editor and authentication flow are exactly the kinds of things where client-side React makes sense.

The public site, however, is generated into actual HTML.

The architecture now looks roughly like this:

                              ┌───────────────────────────┐
                              │ Cloudflare Access        │
                              │ SaaS OIDC + PKCE         │
                              └─────────────┬─────────────┘
                                            │
                                            ▼
┌──────────────────┐   build-time   ┌─────────────────────────┐
│ Astro SSG        │◄───────────────│ Rust Worker + axum      │
│ Cloudflare Pages │     fetch      │                         │
│                  │                │ D1: metadata + keys     │
│ Static HTML      │                │ R2: Markdown + covers   │
└────────┬─────────┘                └────────────┬────────────┘
         │                                       ▲
         │ rebuild after publish                 │
         └──────── Pages build hook ─────────────┘

Astro generates the homepage, pagination, individual post pages, archives, tag pages, category pages, RSS, robots.txt, and the sitemap at build time.

Markdown is converted to HTML during the build as well.

Each page can now have its own title, description, canonical URL, Open Graph metadata, Twitter metadata, and JSON-LD.

In other words, when a crawler requests a blog post, it receives a blog post.

Revolutionary technology.

D1 is a database, not my filesystem

The second major change was storage.

In Version 1, the posts table contained the article body itself:

content TEXT

That wasn't catastrophic for a tiny personal blog, but after revisiting the architecture, I didn't really see a reason to keep large Markdown bodies or file data inside D1.

So Version 2 separates queryable metadata from objects.

D1 now stores things like:

  • title
  • excerpt
  • publication date
  • category
  • tags
  • word count
  • read time
  • content_key
  • cover_key

R2 stores the actual files:

posts/<generated-key>.md
covers/<generated-key>.<extension>

This also creates a nice boundary in the API.

Listing posts never needs to touch R2.

The list endpoint reads metadata from D1 and returns it.

Only the post-detail endpoint resolves content_key and performs an R2 read for the Markdown.

The same idea applies when creating or editing a post. The editor uploads the Markdown and cover image first, receives their R2 keys, and then sends those keys as part of the post metadata.

D1 stays small.

R2 stores bytes.

Everyone gets to do the job they were designed to do.

Amazing what happens when you rebuild a project after not looking at it for a year.

Static generation creates another problem

Of course, moving to static generation introduced a new question.

If all article pages are generated at build time, what happens when I publish a new article?

Saving it to D1 and R2 doesn't magically change the already-generated HTML.

I could manually redeploy the frontend after every article.

I know myself well enough to know that this would result in approximately zero future articles being published.

So the publishing flow now looks like this:

Markdown editor
      │
      ├── upload Markdown ──────► R2
      ├── upload cover ─────────► R2
      │
      └── save metadata ────────► D1
                                   │
                                   ▼
                           Pages build hook
                                   │
                                   ▼
                            Astro rebuild
                                   │
                                   ▼
                         New static HTML

After the database save succeeds, the Worker calls a Cloudflare Pages build hook.

Pages rebuilds the frontend from Git HEAD, while Astro fetches the current content from the API during the build.

That means a content-only update requires no Git commit and no manual deployment.

I press save.

The backend stores the post.

The frontend rebuilds.

A little while later, the static site contains the new article.

That's much closer to the publishing experience I originally wanted.

Authentication found a new way to hurt me

Remember when I said I had already dealt with OIDC?

Version 2 replaced my self-hosted Keycloak integration with Cloudflare Access used as a SaaS OIDC provider.

The admin frontend uses Authorization Code + PKCE.

There is one complication: browsers can't directly call the Access token endpoint because it doesn't provide the CORS headers needed for that flow.

So the Worker acts as a small BFF.

The frontend obtains the authorization code and sends the code, verifier, and redirect URI to:

POST /api/auth/exchange

The Worker then talks to Cloudflare Access's token endpoint and returns the result.

Protected routes verify the Access JWT using JWKS, including the issuer and audience.

Conceptually, this is all fairly normal.

Then production started panicking.

The culprit was jsonwebtoken.

I had upgraded from version 9 to version 11. Version 11 requires exactly one crypto provider feature, such as:

jsonwebtoken = { version = "11", features = ["rust_crypto"] }

Without it, decode() can panic because there is no configured crypto provider.

Even better, the bug initially hid itself because authentication was broken at the same time.

No valid token reached the middleware.

Therefore, the broken JWT verification code wasn't being executed.

After fixing login, I successfully unlocked the next bug.

This is what we call progress.

For Workers, I chose rust_crypto rather than aws_lc_rs, since the latter isn't suitable for my wasm32-unknown-unknown target.

I also added a regression test that executes the same decode path used by the middleware.

Future dependency upgrades are now slightly less likely to turn authentication into a production panic.

Hopefully.

Why I replaced Keycloak with Cloudflare Access

There was also a more practical reason why Version 2 stopped using Keycloak.

My Keycloak instance used to live in my homelab.

At the time, the homelab was relatively conventional: Proxmox VE running a Kubernetes cluster, with Keycloak deployed as one of the pods. It worked well enough, and since I already had an identity provider running there, using it for the blog seemed like an obvious choice.

Then I decided to upgrade my homelab.

And by "upgrade," I mean I replaced the PVE + Kubernetes setup with an architecture involving a VMM I'm developing myself, DPUs, and a custom control plane I'm also designing myself.

That architecture is still a work in progress.

You can probably see where this is going.

The old Kubernetes cluster was gone.

The new platform wasn't fully operational yet.

Therefore, the Keycloak pod that authenticated my blog was also very much not operational.

I had successfully created an infrastructure dependency where logging into my tiny personal blog depended on the current development status of my experimental datacenter architecture.

This seemed less than ideal.

I could, of course, deploy Keycloak somewhere else. But after operating it for a while, I also started questioning whether I actually wanted to.

Keycloak is powerful, but it is another stateful service to deploy, upgrade, monitor, back up, and occasionally debug. More importantly, putting authentication for externally hosted services inside my homelab created an unpleasant failure mode:

Homelab has a bad day
        │
        ▼
Identity provider goes offline
        │
        ▼
Everything depending on that identity provider
also has a bad day

Then I get to spend the next several hours repairing the infrastructure before I can even begin fixing whatever service I originally wanted to work on.

That's a dependency chain I would rather not have.

So for the new version of the blog, I moved authentication to Cloudflare Access as a SaaS OIDC provider.

This isn't because self-hosting identity is inherently a bad idea. In fact, I enjoyed having Keycloak and the amount of control it gave me.

I just realized that authentication probably shouldn't share a failure domain with the experimental infrastructure I'm constantly breaking on purpose.

Cloudflare Access removes that dependency. My homelab can be completely offline, halfway through a control-plane migration, or sitting in whatever horrifying state my latest experiment has left it in, and authentication for the blog still works.

There is also a nice architectural property here: the blog already lives almost entirely on Cloudflare, so its critical path no longer reaches back into my house just to determine whether I am allowed to edit a Markdown file.

The new homelab architecture — the custom VMM, DPUs, and control plane — deserves its own post once I actually get the whole thing working.

Assuming I don't redesign it again before then.

So yes, I replaced Keycloak with Cloudflare Access for architectural reasons.

The architecture being:

my Keycloak pod was dead.

A few design decisions I'm happy with

After the rewrite, there are a few choices that I particularly like.

Public pages are boring

This is a compliment.

The public blog is mostly static HTML.

It doesn't need to hydrate an entire React application just to display some paragraphs.

JavaScript is used where interaction actually requires it.

The backend doesn't move unnecessary data

Post listings only query D1.

They don't fetch Markdown from R2.

The full body is retrieved only when something actually requests a specific post.

Uploads are simple

I don't use multipart uploads.

The frontend sends raw file bytes directly to the Worker. The Worker validates the type and size, sanitizes the filename, generates a collision-resistant key, and writes the object to R2.

The post payload then contains keys rather than file bodies.

The admin panel is intentionally invisible to search engines

The admin interface remains a client-side React island.

It is excluded from the sitemap and marked noindex.

That's a place where I genuinely do not care about SEO.

Google does not need to index my Markdown editor.

What did rebuilding it actually teach me?

The funny thing is that Version 1 wasn't really a failure.

Most of its individual technical decisions were defensible.

React worked.

Workers worked.

D1 worked.

Keycloak worked.

Rust/WASM worked.

The problem was that I spent too much time thinking about whether the architecture was technically interesting and not enough time thinking about the most important property of a blog:

people and crawlers should be able to read the articles.

Version 2 is still hilariously overengineered for a personal blog.

I'm running Rust compiled to WebAssembly at the edge so that I can publish Markdown files.

I have an OIDC flow with PKCE and a BFF so that exactly one person — me — can access the admin panel.

Saving a blog post triggers a deployment pipeline.

There is absolutely a simpler way to do all of this.

But the architecture now has a much clearer division of responsibilities:

Astro  → static HTML for readers and crawlers
React  → interactive admin UI
Worker → API, authentication and writes
D1     → queryable metadata
R2     → Markdown and images
Access → identity
Pages  → static hosting and rebuilds

And unlike the first version, the complexity is mostly behind the publishing system rather than in front of the reader.

I think that's a much better trade.

What would I do differently next time?

A few things.

First, I would add regression tests around authentication dependencies much earlier. A tiny feature change in a JWT library should not be allowed to take every protected endpoint down.

Second, I might eventually add a "save draft without rebuild" option. Right now, saving content triggers a Pages build. That's fine when I publish approximately one article per geological era, but it would be wasteful if I started editing posts frequently.

Third, I would probably think about SEO before spending a year running a blog whose frontend begins with an empty #root.

That one feels obvious in retrospect.

So, welcome to the blog

My original first post was about how I built this blog.

Then I rebuilt the blog and effectively deleted the database version of that first post.

There is still a Markdown backup sitting around, which feels appropriate.

So this is the replacement.

Version 1 taught me how to build the system.

Version 2 taught me what the system actually needed.

Now that I've spent an unreasonable amount of time building infrastructure for writing blog posts, there is only one remaining problem:

I actually have to write them.