Systems / Cloudflare R2 / Next.js
How to make a GREAT Blog for your Portfolio
How I designed a focused publishing platform with Next.js, Neon, private object storage, revision-based publishing, and a Markdown pipeline built for technical writing.
On this page
Most publishing systems begin with a deceptively small requirement: put words on a page.
Then reality arrives. Drafts must be private. Published work must stay stable while the next revision changes underneath it. Images need a home. Code blocks need highlighting. Diagrams need to render without becoming a security problem. Search engines should discover the writing, but never the authoring surface. And the whole thing should feel fast without requiring a small operations team.
That was the brief behind Editorial—the site you are reading now.
I did not want a generic CMS wearing a custom theme. I wanted a narrow publishing system whose architecture reflected the way I write: Markdown first, visually deliberate, technically expressive, and controlled by one author.
Start with the shape of the system
Editorial is a single Next.js application deployed on Vercel. It owns the public site, the private editor, the API, authentication checks, and media delivery. Structured data lives in Neon Postgres. Binary media lives in a private Cloudflare R2 bucket.
That gives the system three durable pieces:
- Next.js owns behavior and presentation.
- Postgres owns truth and relationships.
- R2 owns large immutable objects.
The diagram is small because the system is small. That is a feature. There is no queue waiting for a job that completes in milliseconds, no separate CMS to synchronize, and no public storage bucket whose permissions need constant attention.
The important boundaries still exist. They are simply expressed inside one deployable application.
One application, several trust zones
The public and private experiences share a codebase, but they do not share trust.
Public routes can read only published articles and published media. Author routes sit under /create, use noindex, nofollow, and nocache, and require a valid Better Auth session. That session alone is not enough: the linked GitHub provider account must match one configured numeric GitHub ID.
Using the numeric provider ID matters. Usernames can change; provider IDs are stable. The /create link is absent from public navigation, but obscurity is only a presentation choice. Authorization is enforced again inside every author API route.
This creates a useful rule: the interface may guide the user, but the server decides what is allowed.
Articles are pointers to revisions
A mutable articles table looks convenient until editing and publishing happen at the same time. If the public page reads the same row the editor is changing, an autosave can accidentally publish half-finished prose.
Editorial separates identity from content:
- An article owns the slug, status, timestamps, and revision pointers.
- A revision owns the title, excerpt, Markdown, topics, and cover metadata.
- Saving creates a new immutable revision.
- Publishing moves the public pointer to the chosen working revision.
The working revision can move forward while the published revision remains fixed. Readers receive a coherent snapshot; the author can keep editing without fear.
Each save also carries a version number. If two tabs attempt to save from the same old version, the second receives a conflict instead of silently overwriting newer work. For a single-author site, this is a compact form of optimistic concurrency control—and it solves the problem that actually exists.
Neon is accessed through Drizzle’s HTTP driver. That suits serverless execution because requests do not depend on a long-lived database connection. Multi-statement writes use Neon’s supported batch transaction API, keeping article and revision creation atomic.
Media takes the direct route
Images can quietly become the most expensive part of a writing platform. Sending every upload through a server function wastes memory, bandwidth, and execution time. Editorial keeps the application in control without making it carry the bytes.
Before signing an upload, the API validates its declared type, size, filename, and alt text. The browser then uploads directly to R2 using a five-minute presigned URL. Completion is a separate step: the server checks the stored object, reads its actual image metadata with Sharp, and rejects content that does not match the request.
The bucket remains private. Draft media is visible only to the author; publishing marks referenced media as public. Readers still request /media/:id from the application, which verifies visibility before streaming the object and attaching an immutable cache policy to published images.
That arrangement buys three things at once: private drafts, controlled public access, and an upload path that does not funnel large files through the application server.
Markdown is treated as a language
The editor stores source Markdown rather than generated HTML. Markdown is portable, diffable, and durable; it also keeps the database independent of whichever rendering library the site uses five years from now.
The rendering pipeline adds the features technical writing needs:
Markdown
→ GFM, math, directives, callouts
→ sanitize HTML structure
→ Mermaid, KaTeX, highlighted code
→ heading IDs and anchor links
→ copy controls and interactive enhancementsSanitization happens before enhanced output reaches the page. Mermaid runs in strict security mode. Code blocks are highlighted on the server, while small client-side enhancements add copy buttons and render diagrams. The author sees the same pipeline in the split preview, so the published result does not become a surprise.
This is also why images use internal media IDs in Markdown. The prose points to /media/<id>, not a vendor-specific object URL. Storage can move later without rewriting every article.
Performance comes from choosing the right work
The fastest request is the one the application does not have to repeat.
Editorial applies that principle at a few levels:
- Published JSON APIs advertise a short shared-cache lifetime with stale-while-revalidate.
- Published media uses long-lived immutable caching.
- Static brand assets and metadata images are produced at build time.
- Dynamic routes query current article state only when necessary.
- The sitemap renders on demand, so a deployment does not depend on a live database query during its build.
- R2 handles upload bandwidth directly.
There is another performance win that is easy to miss: the page ships an article, not an editing application. CodeMirror, author controls, upload tooling, and the media library live behind the private author route. The public bundle stays focused on presentation—Markdown, code treatment, and diagrams.
The database model is indexed around the real queries: unique slugs for direct lookup, status for published listings, user IDs for sessions, and article/version pairs for revision history.
At today’s scale, clarity beats speculative machinery. The public index performs a small set of straightforward reads. If the catalogue becomes large, the next improvements are equally straightforward: replace revision lookups with a join, add cursor pagination, and cache rendered article payloads by published revision ID.
Scaling by pressure point
“Scalable” does not mean “already distributed.” It means growth has somewhere clean to go.
Reader traffic mostly increases cache hits and stateless Next.js invocations. Media growth lands in object storage rather than the database. Article growth can be handled at the query layer without changing the content model. If Editorial ever becomes multi-author, the authentication boundary can grow into roles and ownership checks while revisions remain unchanged.
This is the quality I care about most in architecture: the next version should extend the current model instead of escaping it.
The failures improved the design
Building the system exposed two useful mistakes.
First, the original sitemap was statically generated and queried Postgres during the production build. A configured but unmigrated database could therefore block deployment. Making the sitemap dynamic separated build availability from database availability, while migrations remained an explicit deployment step.
Second, the initial repository used the familiar db.transaction() shape. The Neon HTTP driver deliberately does not support interactive transactions; it supports batched transactions. Reworking article writes around known UUIDs and an atomic batch aligned the code with the execution model instead of pretending the serverless transport was a persistent connection.
Neither fix required a new service. Both required a more accurate mental model.
What I would add next
The current system is deliberately complete for one author. Growth should be earned by observed pressure.
If readership becomes substantial, I would add revision-keyed rendering caches and image transformations at the edge. If the archive grows into thousands of pieces, I would move the homepage query to cursor pagination and a joined projection. If collaboration becomes real, I would introduce author ownership, roles, review states, and an audit log.
I would not begin with any of those things. They add surface area before they add value.
Editorial works because its design follows the content lifecycle closely:
- Authenticate one trusted author.
- Save every meaningful change as a revision.
- Publish a stable snapshot.
- Keep media private until the snapshot references it.
- Cache what readers can safely reuse.
The result is not a collection of fashionable services. It is a publishing path with explicit state, narrow trust boundaries, and room to grow.
That is the entire architectural argument: build the smallest system that preserves the things you cannot afford to lose—identity, drafts, publication integrity, and the reader’s time.