How I built a small publishing platform in a fortnight

Drafts, a real editor, per-author pages and a read counter — what it took to build a miniature publishing tool, and the three features I was glad I skipped.

Rowan Vale··4 min read

I wanted a writing tool that behaved the way I write: drafts that stay private until they do not, a preview that matches the published page exactly, and no plugin ecosystem to maintain. Two weeks later I had one, and using it taught me more about product scope than about code.

A two-pane writing app: markdown source on the left, the rendered article on the right.

The data model fits on a napkin

Three tables. That is the whole thing.

authors   id, handle, display_name, joined_at
posts     id, author_id, slug, title, body, status, published_at
reads     post_id, day, count

status is draft, scheduled or published. Everything else — the archive page, the author page, the RSS feed — is a query against those three tables. Every time I was tempted to add a fourth, it turned out to be a column.

The one thing I would keep from this project if I threw the rest away: slugs are assigned once, at first publish, and never regenerate. A title can change afterwards; the URL cannot. Fixing that later means a redirect table, and a redirect table means a fourth table.

The editor is a textarea

I spent a day and a half evaluating rich-text editors before admitting that I write markdown and so does everyone I would give this to.

The editor is a textarea, a preview pane, and a debounce:

const editor = document.querySelector('#editor');
const preview = document.querySelector('#preview');

const render = debounce(async () => {
  const response = await fetch('/api/preview', {
    method: 'POST',
    headers: { 'content-type': 'text/plain' },
    body: editor.value,
  });
  preview.innerHTML = await response.text();
}, 250);

editor.addEventListener('input', render);

Rendering the preview on the server, with the same pipeline as the published page, is the whole trick. A client-side markdown renderer will drift from the server one within a month, and then “it looked different in the editor” becomes a permanent bug class.

Autosave, and how to make it trustworthy

Autosave is easy. Autosave that people believe is not.

Three rules ended up mattering:

  • Save on a two-second idle timer, not on every keystroke.
  • Show the state in words — “saving”, “saved 12:41”, “not saved” — never a spinner that could mean either.
  • Keep the last five versions per post. Not full history; five is enough to undo the accident and small enough not to think about.
async function save(post) {
  setStatus('saving');
  try {
    const response = await fetch(`/api/posts/${post.id}`, {
      method: 'PATCH',
      body: JSON.stringify({ title: post.title, body: post.body }),
      headers: { 'content-type': 'application/json' },
    });
    if (!response.ok) throw new Error(String(response.status));
    setStatus(`saved ${formatTime(new Date())}`);
  } catch {
    setStatus('not saved — retrying');
  }
}

The catch sets a visible state rather than logging quietly. If a save fails and the interface says nothing, the reader keeps typing into a buffer that is about to disappear.

Scheduling is a status plus a timestamp

I nearly built a job queue for this. What I built instead:

UPDATE posts
   SET status = 'published'
 WHERE status = 'scheduled'
   AND published_at <= now();

A cron entry runs that every minute. Publishing is idempotent, the query is one line, and there is no queue to drain when something goes wrong. For a tool with one writer, an hourly newsletter’s worth of infrastructure would have been an elaborate way to be late.

Counting reads without tracking anyone

I wanted a number next to each post and no analytics vendor.

The counter increments once per post per day per rough visitor bucket, where the bucket is a daily-rotating hash of the IP and user agent with a server-side salt. Nothing identifying is stored, and the row is just (post_id, day, count).

const bucket = await sha256(`${salt}:${today}:${ip}:${userAgent}`);

It is deliberately imprecise. Two people behind the same office router count once, and I decided I did not care: I wanted to know whether a post found fifty readers or five thousand, and this answers that.

The three things I did not build

Comments. Every version of this project that had comments became a moderation project. Replies live on the platforms people already argue on.

A theme system. One layout, edited directly. A theme system is a promise to keep two designs working forever.

A media library. Images go in a folder, referenced by path. The day I need cropping and alt-text management, I will know, and it will be a different fortnight.

Was it worth it

For a general audience, no — there are good tools and they are cheap.

For me, absolutely, because the thing I actually wanted was a writing surface that never surprises me, and that is precisely the requirement no general tool can meet. The whole system is under a thousand lines, I have not touched the deployment in eight months, and I have written more since building it than in the two years before.