A description of the mechanism
The Family Recipe App: development and architecture
A recipe book that is meant to be taken
This is a small bilingual recipe app. You browse thirteen recipes by station, open one, read the ingredients and the cook steps and the plating, and quiz yourself on what you just read. You can do all of it in English or Spanish. There's no account, nothing to sign up for, and nothing stored about you.
The recipes are invented. A coastal kitchen that does not exist. That's the most important design decision in the whole thing, and this document is the account of why.
The public tier is what the private tier spawns
This app has a parent, and the parent is a real piece of client work: a kitchen training tool I built for a restaurant I was working in. Line cooks browse recipes on their phone and quiz themselves, a manager edits the recipes and watches which questions people miss. It works, it's still theirs, and it's still login-gated. I'm not going to name them here, because I haven't asked them whether I can, and "he built us a thing" is their fact to share rather than mine.
That pattern is the one I keep landing on: the thing I build for a specific person or a specific kitchen turns out to be the interesting one, and then there is a public version that is a different piece of software rather than the same one with the door left open.
Which is what this is. It isn't the client's app with a demo mode. A separate app, a separate repository, a separate deployment.
Deleting the login was the whole job
The obvious way to make a client tool public is to add a demo account, or a flag that turns auth off. I did neither, because both of them leave the door in the wall.
A demo account is still an account, so there's still a session table, still a login screen, still a password to leak or reset. A mode flag is worse: one wrong environment variable on one deploy and the public build is serving the client's instance. That isn't a hypothetical failure, it's the most ordinary kind of production incident there is.
So the auth isn't disabled in this build. It's gone. No login page, no session cookie, no session table, no middleware, and no admin area at all: no recipe create, no edit, no delete, no CSV import. Those routes return 404 because there is nothing behind them to return.
The check that this is true isn't that I remember deleting them. It's that /login, /admin,
/api/auth/login and /api/admin/recipes all answer 404 on the running app, and I went and asked
them.
And the history is clean too. I didn't fork the client's repository. I copied the tracked files into an empty directory, took the client's name out, and then made the first commit. A fork would've carried their name in the public log forever, and a public git history isn't something you can quietly fix later. I know that because a sibling project of mine has passwords in its history that can never be removed, only rotated around.
The theme is the thesis, again
The recipes being fictional isn't a legal hedge. It's what makes the app a gift rather than a display case.
A demo full of somebody else's real recipes is something you look at. A demo full of plainly invented ones is something you take: fork it, delete my made-up coastal menu, put your grandmother's handwriting in, and invite the rest of the family to the repository so they can add theirs. The git history then shows who added what and when, which is a better keepsake than a folder of photographs of index cards.
That's also the honest answer to where the data lives. There's no cloud account holding your family recipes, because there's no account at all. There's a repository, and it's yours.
How it is built
Next.js 15 with the App Router, Drizzle over SQLite, Tailwind. The recipes live in
ingest/recipes.csv, get seeded into the database by a script, and the quiz questions are generated
from the recipe content rather than written by hand.
The one architectural thing worth knowing: the Spanish is in the database, not in a translation
call. Each recipe carries title_es, ingredients_es, cook_steps_es, plate_steps_es and
plateware_es alongside the English. A script fills them in once, at seed time, using a model. After that
the app just reads columns. There's no API call at runtime, no key needed to run it, and no per-visitor
cost.
Four things that were harder than they looked
The Spanish was a veneer, and only clicking through found it. The UI chrome translated beautifully
— buttons, filters, navigation, headings — and the recipe content didn't translate at all. The
_es columns existed and were populated 0 out of 13, because the translation script had never actually
been run. Every code review of this app would've passed. Loading the page in Spanish and
looking at it took ten seconds.
Two more bugs came out of the same click-through. The station badge on each recipe card rendered the raw database value, so the filter buttons said SALTEADO while the card directly underneath said SAUTE. And the quiz drawer opened with the English recipe title on top of an otherwise Spanish page, because the server component that renders the page can't know which language the browser has chosen, and it was only passing down one of the two titles.
The denylist scan was lying to me. The bar for this app was that a scan of the built output finds
zero mentions of the client, and that I show the scan failing before I trust it passing. Just as well,
because the first version of the scan used grep -ril -e term1 -e term2, and on this shell that form
silently returns nothing at all while grep -ril term1 works correctly. It reported a clean tree.
It would've reported a clean tree forever, with the client's name sitting in plain sight. The only
reason I know is that I planted a control string and it didn't trip.
The seed data was innocent and I nearly rewrote it anyway. The plan said to replace the recipes with fresh fictional ones, on the assumption they were the client's real procedures. Measured: thirteen generic pub dishes, zero mentions of the client, generic station names, empty marketing copy. The data had been de-branded long ago; the chrome never had. That's the opposite of what everyone assumed, including me, and it's why the plan's step for this item read "find the de-branded copy" when the useful answer was that there wasn't one to find.
The agent is switched off, and that is a decision
There's a recipe chat agent in here. It works. It's off.
With no accounts, /api/chat would be an open endpoint that spends real money on an API key every
time anybody pokes it, and an open LLM endpoint attached to a toy is an abuse surface with no upside.
So AGENT_ENABLED is false, the code ships intact, and the README explains how to turn it on in your
own fork behind your own key.
It's the same trade as the fictional recipes and the deleted login. The public version of a feature is a different feature.
The checks, and how I know they can fail
Every claim above has a check behind it, and each check was shown failing first:
- the build gate caught a syntax error I introduced while renaming things, which is how I know it was looking
- the removed routes were confirmed by fetching them and getting 404, not by remembering the deletion
- the denylist scan was proved by planting the client's name and watching the count go up, then removing it and watching it go down. That is the check that caught the scan itself being broken
- the Spanish was verified by clicking through both the list and a recipe in
es, which is the only method that found any of the three bugs
What is not finished
Measurement units, yield, shelf life and allergen names still render in English on a Spanish page. They live in columns that have no Spanish twin. Everything a person actually cooks from is translated; the metadata around it isn't, and calling that finished would be a lie.
There aren't any tests. The build gate and a live walkthrough carry the whole load, which was fine for a client prototype and is thinner than it should be for something offered as a starting point.
The app loads its fonts from Google, which means every visitor's browser tells Google they were here. Nothing in the app claims otherwise, so it isn't dishonest, but it sits badly against the rest of the argument and self-hosting them isn't hard.
What it taught me
The interesting version of "make it public" is almost never "add a demo login". Three times in this build the right move was to remove the thing rather than gate it: the auth, the real recipes, the chat agent. Each time the removal made the app easier to explain, and each time it took away a way for the project to embarrass somebody.
And the other one, which I keep relearning: reading code doesn't find the bugs that clicking through finds. Every one of the Spanish failures was invisible in the source and plain on the screen.
Provenance
Repository: github.com/ErnestOfGaia/family-recipe-app. Written by Ernest (they/them), September 2026.
This file is also the page at /under-the-hood/. There's one copy of it.
If you want to talk about building something like this, text 503-664-0546 or email eog@ernestofgaia.xyz.