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.
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.
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.