This blog has no database table, no admin screen and no CMS. A post is a file in the Rails application, and publishing one is a pull request to main. This post explains why we went that way and what the small amount of code around it does.
A post is a file
Every post lives in app/content/blog/posts/ as YYYY-MM-DD-slug.md. The slug in the filename becomes the URL, and a YAML front matter block at the top carries everything the templates need:
---
title: "Post title"
description: "One-sentence summary used as the meta description and card excerpt"
author: william
category: guide
tags: [google, serp]
cover_image: cover.png
cover_image_alt: "What the image shows"
draft: true
---
The body is GitHub-flavoured Markdown rendered by Kramdown. Fenced code blocks are highlighted client-side, images sit next to the post under app/assets/images/blog/<slug>/ and are referenced by filename, and a missing alt text is a load error rather than a warning. Every post needs a cover: it is the card thumbnail, the image above the article and the social preview.
Loading without a database
Blog::Post reads every file in the directory, parses the front matter, renders the body and keeps the result in memory. In development that happens on every request so edits show up on reload; elsewhere it happens once. Posts are sorted newest first and deduplicated by slug.
A file that fails validation, whether the front matter is missing a field, the author handle is unknown, the cover is missing or an image has no alt text, is skipped. The reason is logged and reported to Honeybadger, and the rest of the blog keeps working. The alternative, one bad file returning a 500 for the index, the author pages and every post that links to it, was the failure mode we most wanted to avoid.
Publishing is a single predicate:
def published?
(Rails.configuration.x.blog.preview_drafts || !draft?) && date <= Time.now.utc.to_date
end
preview_drafts is on in development and on staging, so a draft can be reviewed on a real deploy before anyone removes the draft: true line. Dating a post in the future schedules it: it goes live at midnight UTC without a deploy.
Two kinds of post
Markdown posts render inside the marketing layout with the site navigation, footer and styles. A post can also be a complete .html document with the same front matter on top. It is served as-is, so it brings its own CSS, but the app still adds what it can check for: a <title>, meta description, canonical URL, viewport and og:* tags when the page does not define them, BlogPosting JSON-LD, and the analytics snippets every other page carries.
What the page does for you
A few rules are enforced in code rather than documented and hoped for:
- External links get
rel="nofollow noopener noreferrer"and open in a new tab. - Relative image paths resolve through the asset pipeline and get
widthandheightattributes, so the page does not shift while images load. - An image title becomes a
<figure>caption when the image stands alone in its paragraph. - Related posts are ranked by shared tags, then by matching category, then by date.
- The index, every post and every author page emit JSON-LD, and
/blog/sitemap.xmllists them all.
Why files
A post is a diff. It gets reviewed like code, ships with the deploy that merges it, and can be reverted the same way. There is nothing to migrate and nothing to back up separately. For a blog written by the people who also write the application, that turned out to be the simplest thing that works.