Skip to content
All posts

A help centre with no CMS, no index and no build step

Twenty-two guides, full-text search, and permanent redirects, from a folder of Markdown files and about three hundred lines.

Ada Whitfield

3 min read

The help centre here is a folder of Markdown files. There is no database table, no admin screen, no headless CMS account, and no separate build that has to finish before the site can deploy. Writing a guide means adding a file; the sidebar, search, table of contents and previous/next links all follow from it.

That is a deliberately small design, and it holds up better than it sounds.

Content is code

Every guide is a .md file with a short frontmatter block:

---
title: Two-factor authentication
description: Turning it on, recovery codes, and losing your phone.
category: security
order: 1
keywords: 2fa, mfa, totp, authenticator, recovery codes
aliases: 2fa, mfa
---

Because the files live in the repository, documentation goes through code review with the change that made it necessary. A pull request that alters the billing flow and does not touch the billing guide is visibly incomplete. No CMS achieves that, because no CMS is in the diff.

Vite inlines the whole corpus at build time:

const files = import.meta.glob('../../../content/docs/*.md', {
  query: '?raw',
  import: 'default',
  eager: true,
})

There is no filesystem read at runtime and nothing extra to copy into the container. The content ships inside the bundle, which also means it cannot drift from the code it documents.

Search without an index

The obvious next move is a search index, and it is the wrong one at this size. A few dozen articles can simply be scanned and scored on every query, in well under a millisecond, on the server:

  • Title matches score highest, higher again when the title starts with the term.
  • Then keywords, description, headings.
  • Body matches score on a log scale, so a word repeated fifty times is not fifty times more relevant.
  • Every term must match somewhere, or the article is not a result at all.

An index would be a build artefact to generate, ship, invalidate and debug. When the corpus outgrows a scan, the right answer is Postgres full-text search, not a bespoke index. Until then, the feature is a hundred lines and no operations.

The filename is the URL

Slugs come from filenames, which means the filesystem guarantees uniqueness for free and no article can ever claim another's URL.

The cost is that renaming a file breaks every inbound link. So frontmatter carries aliases, and an alias answers with a 301 to the current URL. Bookmarks, inbound links and search rankings all follow the rename.

Aliases do not get uniqueness for free, so collisions are resolved deterministically at runtime: a real article always beats an alias, and the first claim on a duplicate wins. Both cases log a warning, and two tests over the corpus fail on either mistake, which is where a content error should surface rather than in production.

They also turn out to be useful in their own right, entirely apart from renames. /help/2fa landing on the two-factor guide is a URL you can say out loud in a support chat.

When to outgrow this

When non-technical people need to publish without a pull request. That is the real line, and it is not about scale.

Until then, files in a repository are the cheapest documentation system there is, and the only one that stays in step with the code by construction.

Ada Whitfield

3 min read

Subscribe

Keep reading