Background
I run more than twenty small sites. Nearly all of them are the same shape: build, emit static files, push them to Firebase Hosting, done.
One site is different. Its articles live in Firestore, and a Cloud Function renders every page and pushes the result to Hosting. Reaching for my usual habits on that one site broke things more than once.
This post covers how that dynamic delivery is wired, and — more usefully — what had to be added after deciding where the source of truth lives.
How it works
Articles are Markdown files in the repository. The path from file to page:
repository
functions/seed/articles/<slug>.md <- the source of truth
|
| firebase deploy --only functions
v
deployed function bundle (the seed ships inside it)
|
| call setupInitialData
v
Firestore, articles collection
|
| call redeployHosting
v
every page rendered and pushed to Hosting
The second step is the interesting one. The importer reads the seed from the deployed function bundle, not from the repository. The code says so:
// NOTE: this reads seed/articles/ from the **deployed function bundle**,
// not from the local repository. Adding a new markdown file locally is not
// enough; `firebase deploy --only functions` has to rebuild the bundle first.
const articlesDir = path.join(__dirname, "seed", "articles");
Add an article and skip the function deploy, and nothing happens — with no error. The importer reads the stale seed and returns success.
The reason for this shape is that article delivery is exposed as HTTP endpoints. Hitting a URL rebuilds the whole site, independent of my local toolchain. The price is that all three steps must run, in order, for anything to change.
Implementation
Make the import all-or-nothing
The importer validates every file first, then either writes all of them or writes none.
/* Validate everything first and fail **without writing anything** if a single
* file is bad. Failing halfway leaves Firestore in a partial state, so
* validation and writing are kept separate. */
const parsed = files.map((file) => {
const content = fs.readFileSync(path.join(articlesDir, file), "utf-8");
return { file, content, frontmatter: parseFrontmatter(content) };
});
const problems = parsed
.map((p) => articleProblems(p.file, p.frontmatter, p.content))
.filter(Boolean);
Failing midway would leave four of ten articles updated in production, with no record of where it stopped. Splitting validation from writing means a failed run leaves production exactly as it was.
The same pass rejects duplicate slugs:
const bySlug = new Map();
for (const p of parsed) {
if (!p.frontmatter.slug) continue;
if (bySlug.has(p.frontmatter.slug)) {
problems.push(
`slug が重複: ${p.frontmatter.slug} → ${bySlug.get(p.frontmatter.slug)}, ${p.file}`,
);
}
bySlug.set(p.frontmatter.slug, p.file);
}
A duplicate slug means Firestore keeps the last writer, and the older article loses its public URL and starts returning 404. The static build cannot catch this, because it looks at a different directory entirely — so the gate has to sit on the delivery path itself.
An earlier version fell back to the filename when a slug could not be parsed. That fallback is gone. It let malformed front matter pass by accidentally working, which is exactly the failure mode you do not want on the way to production.
Count what exists in production but not in the source of truth
This is the part I would carry to any project.
The importer only adds; it never deletes. That sounds harmless, and it is not. Anything written by some other path stays in production forever. After retiring an older automated pipeline, 58 articles it had created were still sitting in Firestore — articles that existed nowhere in the repository, still being served.
If the seed directory is the source of truth, then anything not in it has drifted out of it. So every import counts them:
/** Lists Firestore article documents that are not present in the seed. */
async function findOrphanArticles(seedSlugs) {
if (!seedSlugs.size) return []; // never treat "seed unreadable" as "everything is an orphan"
const snap = await admin.firestore().collection("articles").get();
const orphans = [];
snap.forEach((d) => {
const slug = d.data().slug || d.id;
if (!seedSlugs.has(slug)) orphans.push(slug);
});
return orphans.sort();
}
The guard on the first line carries the whole function. If reading the seed fails and seedSlugs comes back empty, every article in production looks like an orphan — and if deletion ran on that result, the site would be gone. "Could not read" must never collapse into "there were zero."
By default it only reports. Deletion needs an explicit query parameter, and the response says so:
seed に無い記事が Firestore に残っています。削除するには ?prune=1 を付けて呼んでください。
(Articles not in the seed remain in Firestore. Call again with ?prune=1 to delete them.)
Splitting detection from deletion was the right call. Orphans appear both because something was forgotten and because a migration is still in progress, and no machine can tell those apart.
Gotchas
The deploy succeeds and nothing changes
The costliest mistake was running firebase deploy --only hosting and watching new articles fail to appear. Green command, clean logs, no change.
Of course. As a hosting payload nothing was wrong. The new article lives in the function bundle, so deploying hosting leaves the bundle untouched.
The fix was not documentation but a wrapper that removes the choice of ordering:
// 1) rebuild the bundle (which contains the seed). Skipping this makes the rest pointless
deployFunctions();
// 2) import into Firestore from the deployed bundle
await callFn("setupInitialData");
// 3) regenerate and publish every page
await callFn("redeployHosting");
I tried a bold-red note in the runbook first, and I still got it wrong. A runbook cannot tell you that you made a mistake. When the command succeeds, nothing prompts you to go back and reread it.
Write down what must never be added back
This site's shape differs from the other twenty, and that difference is itself a hazard. The function file opens with a long comment naming the pipeline that was removed, why it was removed, and what must not return:
したがってこのファイルに残すのは「配信系」の HTTP 関数だけである。
ここに LLM 呼び出しやスケジュール生成を再び足さないこと(二重生成に戻る)。
(Only delivery-related HTTP functions belong in this file. Do not add LLM calls or
scheduled generation back here — that reintroduces the duplicate generation path.)
Six months from now I will not remember the history. Recording what not to do has held up better than recording why things are the way they are.
The result
The site running on this setup: https://gadget.autoarticles.net
Wrap-up
The dynamic delivery itself is unremarkable — documents in Firestore, a function that renders pages. What actually mattered was the surrounding design:
-
Pick exactly one place that is the source of truth (here,
functions/seed/articles/). - Count, on every run, what exists in production but not there. An add-only importer keeps yesterday's mistakes alive indefinitely.
- Put detection and deletion behind separate switches. A machine cannot tell "forgotten" from "mid-migration", so a human decides.
Generalized: declaring a source of truth creates an obligation to detect anything that has escaped it. Build only the declaration and the definition stays correct while production quietly diverges — and every command you run keeps reporting success.
This article is about my own side project. It was written with AI assistance.
Top comments (0)