When you're building an indie app, you eventually need a place to write about it. Release notes, deep technical dives, lessons learned — all the things users and fellow developers might find useful.
The easiest option is Substack or Ghost. Ten minutes and you're live.
We didn't do that.
Instead, we built a custom pipeline that turns a folder of Markdown files into a fully integrated blog living inside the Atmos Football React app. Every post is published through a normal git push and Netlify deploy. No separate platform, no extra subscription, and — most importantly — everything lives on one domain.
Here's how we did it, why we chose this unusual path, and the trade-offs involved.
1. Why Not Substack?
Substack is excellent. Great editor, built-in mailing list, analytics. For many projects it's the obvious choice.
We rejected it for three main reasons:
- SEO consolidation. The app already had some backlinks and domain authority at
atmos-football.netlify.app. Splitting content to a subdomain or separate domain would dilute that authority. Every article about Elo ratings or Firestore costs also strengthens the main app in Google's eyes. - Cost and ownership. Substack's free tier is generous until you want custom CSS, advanced features, or to remove their branding. Over time those costs add up, and you're locked into their platform.
- AdSense strategy. One of the driving forces behind the blog was improving our chances of AdSense approval. Google prefers substantial, original content on a single cohesive site. A thin React app plus a separate blog looked weaker than one unified site with real utility plus long-form content.
The main downside is workflow. There is no "Publish" button — publishing means committing the Markdown file and waiting for a deploy. For our pace (a few posts per month), that trade-off has been acceptable.
2. The Source Format
All content lives in content/blog/. One Markdown file per post, using a numbered prefix for stable ordering (001-the-start.md, 002-the-data.md, etc.).
Each file uses standard YAML frontmatter:
---
title: The Start
slug: the-start
date: 2026-03-26
description: Why this blog exists...
tags: [history, ai, development]
author: James
---
The build script validates each file at build time — any post missing a slug or title is skipped with a console warning rather than failing the deploy. Other fields are optional and degrade gracefully (no tags, no author byline, no reading time estimate). It's a deliberately permissive parser, because losing the build over a missing tag is worse than shipping a post without one.
3. The Dual-Output Pipeline
This is the core of the system.
At build time, scripts/build-blog.mjs processes every Markdown file and writes seven artefacts:
- Static HTML article pages (
public/blog/<slug>.html) — pre-rendered for crawlers and direct sharing, with canonical URL and OpenGraph tags injected via a small HTML template. - Stripped Markdown (
public/blog/<slug>.md) — body only, no frontmatter — fetched by the in-app reader at runtime. - Index JSON (
public/blog/index.json) — the list view's manifest: title, slug, date, description, tags, author, word count, reading time. - Static blog listing page (
public/blog/index.html) — the listing equivalent of the article pages, for crawlers landing on/blog/. - Sitemap (
public/sitemap.xml) — main app routes plus every published article, withlastmodfrom each post's frontmatter date. robots.txt— staging getsDisallow: /so preview builds don't get indexed; production advertises the sitemap.- GSC verification file — copied into
public/blog/so Google's verification token is reachable under the blog subdirectory as well as the root.
There's no gray-matter dependency — the YAML frontmatter parser is a small regex helper inside the script (~25 lines, supports strings and [a, b, c] arrays). The only runtime dependency is marked for Markdown → HTML.
The script accepts a --site-url <url> argument so canonical URLs, OpenGraph tags and the sitemap differ per deploy target. There are three matching npm scripts:
"build:blog": "node scripts/build-blog.mjs",
"build:blog:preview": "node scripts/build-blog.mjs --site-url https://atmos-football-13ac0.web.app",
"build:blog:staging": "node scripts/build-blog.mjs --site-url https://atmos-football-13ac0-staging.web.app"
build chains the blog build before vite build, so a single deploy command rebuilds both. At ~310 lines of plain Node (plus a 370-line HTML template), the whole pipeline is small enough to read in one sitting. Adding a new post is git add content/blog/NNN-slug.md && git push — Netlify rebuilds, and the seven artefacts regenerate.
4. Two Renderers, One Source
The most interesting design decision is that the same Markdown source is rendered two different ways, depending on who's reading.
Crawlers and direct-link visitors see the static HTML pages. Google indexing https://atmos-football.netlify.app/blog/the-start gets the pre-rendered HTML produced at build time — title, body, tags, OpenGraph metadata, canonical URL. No JavaScript required.
Users inside the app see the React-rendered version. Under About → Blog, BlogView.jsx fetches /blog/index.json, renders the post list, and on tap fetches /blog/<slug>.md and runs it through the app's existing MarkdownRenderer. No full-page navigation, no static HTML — the post stays inside the SPA shell, with the rest of the app's chrome intact.
Two renderers, one source: the same <slug>.md powers both. The static HTML is what crawlers cite; the SPA-rendered version is what users actually scroll through.
A few app-side details that fell out of this design:
- Article opens on Android fire an AdMob interstitial via
showInterstitialIfReady('blog_article_open'). The blog tab is one of the few places interstitials are timed naturally — the user has just chosen to read something, not been interrupted in the middle of a task. - Reading time is tracked client-side. A
blog_article_readGA4 event fires when the user returns to the listing, including aread_time_secondsfield — but only if they spent at least five seconds on the article. That filter strips accidental taps without needing any explicit tracking on scroll position. - The article view has a
← All articlesbutton that returns to the list. It's React state (setSelectedSlug(null)), not URL navigation, so the back transition is instant.
5. Static Files Under a SPA Rewrite
Hosting a static blog under a SPA rewrite has one quiet mechanism that's worth understanding.
The whole app's _redirects file is one line:
/* /index.html 200
Both Netlify and Firebase Hosting apply the rule the same way: serve any matching file from the build output first, fall back to the rewrite only when no file matches. So /blog/the-start.html resolves to the actual file in dist/blog/the-start.html; /leaderboard (no matching file) falls through to the SPA shell.
Firebase Hosting adds one detail via cleanUrls: true in firebase.json — /blog/the-start (no extension) automatically resolves to /blog/the-start.html. So article URLs work whether or not the visitor types the extension.
Google Search Console verification follows the same path. The verification HTML file lives in public/, gets copied into dist/ at build, and is served as a real file under /google<token>.html. The build script also copies it into public/blog/ so it's reachable under that prefix too — a precaution for the case where GSC's verification probe walks the blog subdirectory.
No special rewrite rules required for any of this. The default "files first, rewrite second" behaviour does the work.
6. SEO Realities at This Scale
With around a dozen posts published, Google has indexed all of them and Bing has indexed most. We're seeing a steady trickle of organic traffic from long-tail searches like "Elo ratings 5-a-side", "Firestore cost optimisation", and "Android WebView rotation blank patches".
Pre-rendered HTML clearly helps. Posts with similar content on Medium or Dev.to (rendered client-side from JSON) tend to rank lower than the static HTML versions of the same post here. Anecdotal but consistent.
Internal linking has also been more effective than expected. A new post that links to two older posts gets indexed faster than a new post that links nowhere.
The sitemap auto-generates on every build with proper lastmod dates. Beyond the blog, it also lists the main app routes (/, /leaderboard, /about, /privacy, /Demo, /blog) with today's date — minor, but it ensures Google revisits them rather than treating them as static.
7. The AdSense Angle
One of the strongest motivations for this architecture was AdSense eligibility.
By keeping the blog on the same domain as the app, we present Google with one cohesive, content-rich site rather than a thin SPA plus a separate blog. The static HTML pages, proper navigation, and growing library of original technical writing all help demonstrate that this is a real, useful site rather than a thin utility app.
Whether this ultimately helps with approval remains to be seen, but the decision has already delivered better SEO and a much cleaner user experience.
8. What I'd Do Differently
A few lessons learned along the way:
- Ship the
index.jsonfrom day one. The first version only output HTML and the in-app list was hardcoded. Deduplicating to one manifest came as an early refactor and shouldn't have. - Consider a static-site framework for the post pages. Astro or 11ty would handle image optimisation, RSS, and a few other niceties for free. The hand-rolled pipeline works well, but bespoke means we maintain it.
- Add a
draft: truefrontmatter flag. Currently drafts live in a separate folder, which works but means renaming a file to publish.
Overall, running the blog inside the React app has been worth the extra engineering. It keeps everything under one roof, strengthens SEO, and gives us full control without platform lock-in or extra costs.
If you're an indie developer trying to decide where to put your blog, ask yourself how important domain authority and seamless integration with your main product are. For us, the answer made the custom pipeline worthwhile.
You can read the blog inside the app under About → Blog, or browse the static posts directly at /blog/.
— James