How I Add Articles to My App Without Releasing It
Separate the things that change at content speed from the things that change at binary speed.
The App Store does not need to gate my typos
Fermento has an Ideas tab full of articles and a fermentation assistant that answers questions over a library of research. Both features depend on content, and content needs a very different release cycle from code.
A safety article needs a correction. A paper gets summarised badly. A recipe idea is worth adding on a Tuesday afternoon. None of those changes justify building a new binary, uploading it, waiting for review, and then waiting again for people to install the update.
So the content does not ship inside the app.
It lives on GitHub Pages. A push can reach every install within an hour, and the device does the rest: fetching, caching, falling back, and re-indexing the material for local retrieval.
The architecture follows one rule: separate the things that change at content speed from the things that change at binary speed.
The publishing side is a folder and a push
The Fermento repository already had a docs/ folder served by GitHub Pages at fermento.andrej-jasso.com. It contained the marketing site, privacy policy, and support page. It now holds two content feeds as well.
The first is the article feed, stored in a single articles.json file:
{
"version": "2",
"lastUpdated": "2026-08-22",
"articles": [
{
"id": "safety-mould-or-kahm",
"title": "Mould, kahm, or the culture you meant to grow",
"category": "Safety",
"purpose": "safety",
"readTime": 4,
"summary": "Tell fuzzy coloured mould from a flat white kahm film…",
"content": "# Mould, kahm, or the culture you meant to grow\n\n…"
}
]
}
The second is the research feed. It uses a manifest plus one Markdown file per source:
docs/sources/
sources.json 147 entries
papers/
alkaline-fermented-foods-review.md
lab-kimchi-succession.md
…
Each manifest entry points to its file and keeps the canonical link to the original source:
{
"id": "alkaline-fermented-foods-review",
"title": "Alkaline fermented foods: a review",
"file": "papers/alkaline-fermented-foods-review.md",
"url": "https://doi.org/10.3390/ijms222312984",
"healthTagged": true
}
Adding research takes three steps:
- Write the Markdown file.
- Add its manifest entry.
- Push.
There is no CMS, admin panel, application server, or separate deployment. GitHub Pages is the CDN. Git is the audit log. A pull request becomes the editorial review whenever I want one.
Publishing is a push. Everything after that happens on the phone.
The client assumes the network will fail
Both feeds use repositories with the same basic contract: fetch the latest material, cache it for an hour, and never turn a failed request into a blank screen.
The order matters more than the caching technology:
- If the cache entry is less than an hour old, return it without touching the network.
- Otherwise fetch with a ten-second timeout and an abort controller, then write the response to the cache.
- If the request fails, return the stale cache anyway.
- If no cache exists, return a small set of articles bundled in the binary.
That fourth step is the one people forget.
A brand-new install opened in aeroplane mode still shows something in the Ideas tab. The bundled seed is not live content, and it will drift over time, but it removes the state in which the feature has nothing to display.
Pull-to-refresh skips the fresh-cache check and forces a network request. That is the only manual override the feature needs.
The research feed follows the same strategy with one extra loop. The app fetches the manifest, then downloads each Markdown body sequentially. Deliberately sequentially. It is a small feed on a static host; sending 147 parallel requests to save a few seconds would be rude and would buy very little.
The interesting part is not displaying the article
Rendering a fetched article is straightforward. The assistant is what made this architecture worth writing about.
The assistant does not simply read the feed. It retrieves over it. New content is useless to retrieval until something has chunked and embedded it, and all of that work happens on the phone.
Once an embedding model is available, Fermento pulls both feeds, converts their entries into knowledge sources, and passes them into an idempotent sync. Each source gets a compact content hash, and the comparison determines the cheapest correct action:
- Content and metadata are unchanged: skip the item entirely. This is the common path, so an ordinary launch stays cheap.
- Content is new or edited: mark the source as
pending-modeland clear its old chunks. Stale text must not keep grounding answers while the replacement waits to be embedded. - Only metadata changed: update the record and leave the vectors alone.
A separate pass then walks everything marked pending-model and embeds it.
The metadata-only path came from an audit.
Retrieval metadata decides what a source was written for, which ferments it applies to, and whether it is health-related. Reclassifying a source has to reach existing installs. That is how the app prevents an article about sourdough from grounding advice about kombucha.
But a classification change does not alter the source text. When metadata and content shared one version boundary, every reclassification triggered a full re-embed. Splitting those concerns turned an indexing job into a database write.
There is one change that really does justify rebuilding the entire index: replacing the embedding model.
Vectors produced by different embedding functions do not belong in the same search space. Fermento stores the embedder identifier alongside the index. When the identifier changes, the app discards the old vectors and rebuilds them with the new model.
Why the phone creates the embeddings
The obvious alternative is to precompute every vector during publishing and include the results in the feed.
I chose not to, for a practical reason rather than a universal principle. The vectors would add roughly 20 MB of JSON, and the user is already waiting for a model download at the exact moment the first indexing pass would run.
The guarantee I care about is not no indexing ever. It is no indexing delay when someone asks a question.
Running the initial index build during a wait that already exists satisfies that requirement without adding a vector publishing pipeline.
It also keeps the content feed independent from the embedding model. If the feed shipped vectors, changing the model would require republishing the corpus. Publishing text and embedding it locally allows those two systems to evolve separately.
The local pipeline already existed for another reason: people can add their own reference material inside Fermento. Publisher content and user content use the same chunking, embedding, and retrieval path. The remote feed is not a special case. It is simply another source of sources.
What this design costs
This is a small system, not a free one. Its trade-offs are part of the architecture.
The folder is public
GitHub Pages serves a website. That rules out copying the full text of paywalled papers into the feed, which would be a copyright problem regardless of how technically convenient it might be.
Abstracts, open-access material, and my own summaries are suitable. The manifest keeps the canonical DOI so a citation can still open the actual paper.
There are no schema migrations
The JSON has to be parsed by whichever app version happens to be installed, including versions from months ago that may never be updated.
The feed can gain optional fields. It cannot casually rename or remove existing ones.
The client therefore carries compatibility fallbacks: an unclassified article defaults to downstream content, and a source without declared ferments can still use title-based matching. Those defaults are not elegant. They exist because an old binary must keep working against a newer feed.
A bad push reaches everyone
There is no mandatory review gate between a commit and every active install. That is the point, and it cuts both ways.
A mistake can propagate within an hour. So can its correction.
Compared with a binary release, where the mistake might take a week to arrive and the fix another week after that, I will take the hour. But speed does not remove the need for review; it makes a clean Git history and optional pull-request gate more important.
The cache can lie for an hour
Someone who opens Fermento immediately after I publish may still see the previous version.
For a fermentation article, that delay is irrelevant. For time-critical information, it would make this the wrong design. Cache duration is a product decision disguised as a technical constant.
The rule I would keep
Mobile products run on at least two clocks.
Code changes at binary speed. It needs builds, review, compatibility checks, and a release process. Content changes at editorial speed. It needs traceability and care, but it should not inherit the full cost of shipping an executable.
Fermento keeps screens, timers, assistant behaviour, and retrieval rules in the binary. Articles, research summaries, and safety guidance live as files on a static host that I can correct on a Tuesday afternoon.
The App Store gates my code, which is reasonable.
It does not need to gate my typos.