Building the Naventric Knowledge Base
We had already written down how to build this, and then we overturned our own decision. What follows is the architecture that replaced it — including a caching bug we designed out before it could ever leak.
We had a decision record for this. It said: do not build a blog.
The reasoning was sound at the time. Our marketing site already had a content system — a ContentBlock table keyed by page and slot, editable from the admin console, cached and invalidated on write. The help section was three static pages. A fourth did not justify a second content system, and the record said so in as many words.
Then the requirement changed shape. Not "a few more help pages" but a public, multi-author publication: engineers and non-engineers writing regularly, with code samples, diagrams, tables, images, tagging, search, and syndication. We wrote a second decision record superseding the first.
Changing our minds is not the interesting part. The interesting part is which requirement broke the original design, because it is not the one you would guess.
The requirement that broke it
It was not volume, and it was not rich formatting. A content table can hold a lot of Markdown, and Markdown does not care how long it is.
It was ownership.
A ContentBlock is page furniture. It answers "what text goes in the hero slot on the solutions page." It has no author, no publication lifecycle, no notion of a draft that one person is working on and nobody else can see, and no reason to have any of those things. The moment content belongs to a person rather than to a slot, you need a different model — one where the row knows who wrote it, what state it is in, and who is allowed to move it to the next state.
| Page content | Articles | |
|---|---|---|
| Keyed by | (page, slot) |
slug |
| Belongs to | the page | the author |
| Lifecycle | published or not | draft → published → unpublished → archived |
| Who edits | site admins | the author; admins for anything |
| Survives author leaving | not applicable | must |
That last row shaped the schema more than anything else. Article.authorId is nullable, with onDelete: SetNull. When someone leaves the company and their user record is removed, their articles do not vanish with them — they lose a byline, which is a formatting problem, rather than disappearing, which is a data-loss problem. It is one line in a schema and it is the line most likely to matter years from now.
Why not just a folder of Markdown files
The default answer for an engineering blog is MDX in the repository. It is genuinely good: version control, code review on prose, no database, no admin console to build.
We rejected it for a reason that has nothing to do with engineering quality.
Publishing would have required a deploy. That is fine when every author is an engineer with commit access and a local dev environment. It is a wall when your authors include people who write extremely well and have never opened a terminal. The audience for the authoring tool is not the same as the audience for the codebase, and optimising the tool for the people who already have the most power is how a publication ends up with one voice.
So the requirement was a console — and once you are building a console, the content has to live in the database anyway.
Your own staff are untrusted input
Article bodies are Markdown, written by colleagues, stored in our database, and rendered into pages served to the public. Every one of those authors is trusted. The Markdown is still untrusted input.
This is worth being precise about, because "sanitize user input" gets repeated so often that the reasoning behind it goes missing. We are not defending against our own writers. We are limiting the blast radius of everything else: a single compromised author account, a code sample pasted from somewhere that contains more than code, a well-meaning author reaching for raw HTML to get a layout the editor does not offer.
The rendering pipeline is server-side, and the sanitizer is not optional in it:
// Author Markdown is untrusted. Sanitize on the way out, every time.
<ReactMarkdown
remarkPlugins={[remarkGfm]} // tables, task lists, strikethrough
rehypePlugins={[
rehypeShiki, // syntax highlighting at render time
rehypeSanitize, // must come last — it is the gate
]}
>
{article.bodyMarkdown}
</ReactMarkdown>Two decisions inside that block are load-bearing.
Sanitization runs last. Plugins transform the tree in order. A sanitizer that runs before another plugin injects HTML is decoration, not a control.
Highlighting happens on the server. Shiki uses real TextMate grammars — the same ones your editor uses — which makes it both accurate and large. Running it at render time means readers download highlighted HTML rather than a highlighting engine. The cost lands once, on our machines, instead of once per reader, on theirs. A reader on a slow connection gets a fully highlighted code block in the first paint with no client-side JavaScript involved at all.
There is no dangerouslySetInnerHTML anywhere on this path, and that is enforced by convention and by the fact that nobody has needed one.
The diagrams had to break the rule
Diagrams are where that architecture stops being clean.
We wanted Mermaid — diagrams written as fenced code blocks, so a diagram lives in the article body and diffs like text:
```mermaid
flowchart LR
A[Author writes Markdown] --> B[Server renders]
```
Mermaid draws by measuring text in a live DOM. There is no server-side rendering pass that produces the same result, so this one feature genuinely has to run in the browser.
Rather than give up and make the whole article client-rendered, we made the diagram — and only the diagram — a client component. A small rehype plugin rewrites ```mermaid fences into <pre class="mermaid"> nodes on the server, and a client island finds those nodes after hydration and draws them.
The result is that an article with three diagrams and forty paragraphs ships client-side JavaScript for the three diagrams. The forty paragraphs are HTML.
The cache that would have leaked
This is the decision I would most want another team to copy, because the bug it avoids is invisible until it is severe.
Article pages are cached. The rendered HTML for a given article is identical for every visitor, expensive enough to produce that regenerating it per request is wasteful, and invalidated by tag whenever the article changes. Straightforward.
Then we added reactions and bookmarks — and reactions and bookmarks are per reader. Whether the heart is filled depends on who is asking.
The obvious implementation is to fetch the reader's engagement state in the server component alongside the article, and render the bar in the same pass. It works perfectly in development, where you are the only person signed in.
In production it is a data leak. The cache key is the article, not the reader. The first signed-in visitor to miss the cache would have had their engagement state baked into the shared HTML, and every subsequent reader — signed in as someone else, or signed in as nobody — would have been served that first person's likes and bookmarks. Not their data, exactly, but their state, which is enough to tell you what a named colleague has been reading.
The fix is a boundary, not a cache key. Per-user state never enters the cached render at all:
flowchart TD
A[Request for an article] --> B[Server Component]
B --> C{In cache?}
C -->|hit| D[Article HTML]
C -->|miss| E[(Postgres)]
E --> D
D --> F[Response — identical for every reader]
F --> G[Engagement island mounts]
G --> H[Server Action]
H --> I[(Postgres — per-user, never cached)]
The article HTML is shared and cacheable because it contains no per-user data. The engagement bar is a client island that mounts, asks a Server Action for this reader's state, and renders itself. Its buttons are disabled until that resolves — a deliberate half-second of honesty rather than a wrong state that flickers into a right one.
A pleasant consequence falls out of it: toggling a reaction does not invalidate anything. There is no revalidateTag on the like button, because the cached HTML never contained the like state. A popular article can absorb thousands of reactions without regenerating its page once. Had we put engagement in the cached render, every reaction would have invalidated the article for everybody — the cache would have been busiest exactly when it was most needed.
The same reasoning governs view counts. They increment on a fire-and-forget call from the client and are read through a cache with a short TTL. A view counter that invalidated the page it counted would be a cache stampede wearing a costume.
Search wants a column your ORM cannot describe
Search is Postgres full-text search over a generated tsvector column with a GIN index. It is fast, it ranks sensibly, and it required no new infrastructure — which for a publication of this size is the entire argument.
It also has a sharp edge that cost us real time: a GENERATED ALWAYS column is not something Prisma's schema language can express, so the ORM believes the column is something simpler than it is and tries to correct it on every subsequent migration. We wrote that up separately in The Postgres column Prisma cannot see, because it deserves more room than a paragraph.
The general lesson generalises past Postgres: when you step outside what your ORM can model, the ORM does not know it has stepped outside. It keeps generating confident, wrong migrations. Something has to catch that, and that something is a human reading the generated SQL before applying it.
A preview that cannot lie
The authoring console is a split pane: Markdown on the left, rendered article on the right.
The temptation is to render that preview with a lightweight Markdown component — faster, simpler, no need to pull the full pipeline into the editor. We used the same renderer as the public page instead, sanitizer and all.
The reason is that a preview which differs from production is worse than no preview. If the preview is approximate, authors learn not to trust it, and they start publishing-then-checking — which means readers see the draft. Sharing the renderer means the preview cannot drift, because there is nothing to drift from. If a code block highlights in the preview, it highlights on the page. If the sanitizer strips something, the author sees it stripped while they can still do something about it.
Images take the same path in reverse. Uploads go through a signed Server Action to a media CDN, so the credential that authorises an upload stays on the server. The browser gets a signature scoped to one upload, not an API key.
Discovery is a ranking problem, not a listing problem
The last phase was the one that changes whether anybody reads any of this.
Trending is time-decayed, not a raw count. Sorting by views is really sorting by age: the oldest article has had the longest to accumulate them, so a "trending" list built that way slowly freezes and starts advertising last quarter's work. Weighting views against recency lets a two-day-old article outrank a two-month-old one, which is what the word means.
Related articles are computed, not curated. Shared tags and category, scored — nobody maintains a list, so the rail cannot rot when someone retags an article.
Every page has a social card. Articles generate one from their title, category and byline. Categories, tags and author pages generate their own. Before that, every link into the publication previewed identically, which meant a shared link told a reader nothing about where it led.
What we would do the same
Four things, in the order we would defend them.
Write the decision record even when you overturn it. The first record is why we can say precisely what changed — the requirement was ownership, not volume — instead of vaguely feeling that the old approach stopped fitting. A superseded record is not a wasted one.
Decide who your authoring tool is for before you choose your content format. Almost every other decision here follows from "non-engineers must be able to publish without a deploy." Get that answer backwards and no amount of good engineering downstream will fix it.
Draw the boundary between shared and per-user data before you cache anything. Caching a page that contains one reader's state is not a performance bug you fix later; it is a privacy bug you ship. Ask which parts of a response are the same for everyone, and let only those parts be cached.
Prefer the boring database you already run. Full-text search in Postgres, image transforms at a CDN, Markdown in a column. The Knowledge Base added exactly one external dependency to our stack. Every piece of infrastructure you do not add is one you never have to migrate, monitor, or explain to whoever is on call.
The publication you are reading this on is the result. It is not finished — threaded comments need a moderation model before they are worth building, and a newsletter needs a decision about where a subscriber list should live. But the shape is right, and the shape was decided by a handful of choices made early, most of which were about boundaries rather than about technology.