A private log of the recipes you've actually cooked and would cook again — and, just as importantly, where each one came from.
The problem it solves: you cook from books, recipe sites and social posts, and the good ones get lost among the ones you tried once. This keeps the ones that worked in a single place you can search, with the ingredients and method pulled out for you and a link back to the original for when you want to see it as the author wrote it.
Built for one person. No accounts, no feed, no ratings from anyone else.
- Photograph a book — up to six pages, read together as one recipe, so a recipe that runs over a spread and onto the next page comes through whole. Title, ingredients, method, servings and timings are transcribed, along with the page number. Claude also finds the photograph of the finished dish and proposes a crop, which you can drag and resize before accepting. Any page that came out on its side can be turned before it's read. The page photos are kept too, so you can always check the transcription against the original.
- Books are on a shelf. Photograph the cover once and every recipe you log from that book lands on it — the recipe page shows which book it came from, and the book's page shows everything you've cooked out of it.
- Paste a link from a recipe site and have the same thing filled in. Sites
that publish
schema.org/Recipemarkup — most of them do — are read directly from that markup, which costs no API call and is more accurate than reading the rendered page. Anything else falls back to Claude reading the page text. - Paste a social post. Instagram and TikTok serve a login wall to anything that isn't a signed-in browser, so the app tries the fetch, and when it's refused it says so plainly and offers the two things that do work: paste the caption, or add a screenshot. Either gets read the same way.
- Pictures, swipeable. A main image and as many others as the source had. Any picture can be promoted to main; the main one is what the book shows.
- Your own notes — the halve-the-sugar, needs-a-hotter-oven part. This is the bit you'll be glad of in a year, so it gets its own block on the page.
- Search across titles, books, authors, notes and ingredients, because half of remembering a recipe is remembering it was the one with the anchovies. Tags, and filtering by them, are there too.
- Tick things off while you cook. Ingredients and steps are tappable and strike through. Deliberately not saved — that's about where you are in this pan of food, not a fact about the recipe.
- Installs to your phone's home screen and runs full-screen like an app.
You need a Neon database, an Anthropic API key, and a Vercel account. All three have free tiers that comfortably cover personal use.
npm install
cp .env.example .env.localFill in .env.local:
| Variable | Where it comes from |
|---|---|
DATABASE_URL |
Neon dashboard → your project → Connection string (pooled) |
ANTHROPIC_API_KEY |
console.anthropic.com → API keys |
APP_PASSCODE |
You choose it. It's what unlocks the app on your phone. |
AUTH_SECRET |
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" |
npm run db:initSafe to re-run; it only creates what's missing.
npm run devPush to GitHub, import the repo in Vercel, and add the same four environment variables under Settings → Environment Variables. Deploy.
Then on your phone, open the Vercel URL, enter your passcode, and add it to your home screen — Share → Add to Home Screen on iOS, or ⋮ → Install app on Android. HTTPS is required for the camera, which Vercel gives you by default.
| Piece | Choice | Why |
|---|---|---|
| Framework | Next.js (App Router) | One deployable for pages and API; first-class on Vercel |
| Database | Neon Postgres over HTTP | No connection pool to manage from serverless functions |
| Reading recipes | Claude, server-side | Handles a photographed page, a blog, and a caption with one model |
| Recipe sites | schema.org/Recipe first |
Free, instant, and more accurate than reading the rendered page |
| Photos | Base64 in Postgres | One less service to wire up |
| Auth | One passcode, signed cookie | It's a single-user app on a public URL |
| Styling | Tailwind v4 | — |
| Type | Fraunces + Schibsted Grotesk | Both OFL, self-hosted at build, so no runtime call to Google |
On the type. Fraunces (Undercase Type) for recipe names and notes, Schibsted
Grotesk for everything else. Fraunces is loaded as a variable font so its
optical-size axis can thin the strokes for a 36px title and thicken them for a
15px note, out of one file; SOFT and WONK give the serifs their character.
Three classes select the cut — .masthead for the identity mark, .display for
recipe names, .essay for running prose.
On the look. Warm oat paper rather than the mineral greys of a gallery, hairline rules, small letterspaced caps for labels, and a serif reserved for recipe names and your own notes. Food photography is the only strong colour on the page, which is the point: the book is a grid of dishes, and you recognise a meal before you read its name. The one accent is a herb green, used for chosen tags and the link back to the source.
A few decisions worth knowing about:
- Your API key never reaches the browser. Reading happens in route handlers; the phone only ever posts an image, a URL or some text and gets JSON back.
- Pages are stored before they're read, not after. A slow or failed transcription can't lose the photographs you just took, and each upload is its own small request rather than six pages inside one body — which is what used to cap how many pages you could send at once.
- Orientation is baked in on upload. Phones record a landscape shot as a portrait frame plus an EXIF "turn this on the way out" tag, and not everything downstream honours it. The rotation is applied to the pixels when the photo is stored, and there's a Turn control on every picture for the cases that still come out sideways.
- Turning a picture writes a new one. Photo bytes never changing under a given id is what lets the browser and the service worker cache them forever; rotating in place would serve everyone the old orientation until those caches expired. Turn four times and you're back where you started.
- Photos are downscaled in the browser to 2000px before upload. That's more than a wine label would need, because a cookbook page is dense — small type, two columns, sometimes a whole spread — and the transcription is only as good as what it can read.
- Reading is never load-bearing. Every path — photo, link, paste — ends at the same form. If the model fails, is misconfigured, or the page turns out not to be a recipe, you get a sentence explaining why and the form appears anyway with whatever was salvaged. You can always type a recipe in by hand.
- The link is always kept, even when the page couldn't be read. Getting back to the source is half the point of the log.
- Pictures from the web are copied, not hotlinked. Sites reorganise and CDNs expire; a saved recipe shouldn't depend on someone else's server still answering in two years.
- Ingredients and method are stored as groups, each with an optional heading, because real recipes have "For the sauce". A recipe without headings is one group with a null heading, so nothing downstream needs a special case.
- Tags are stored as ids, not labels (
one_pot, not "One pot"), so the wording can change later without rewriting your history. - Nothing is cached offline except the shell and the photos. A stale ingredient list would be worse than an error message when you're halfway through cooking from it. Photos are the exception, and a safe one: a photo is written once under an id that never points at anything else.
- The server refuses to fetch private addresses. The link comes from you, but the URL reader makes the server fetch whatever it's given, so it won't be pointed at localhost or a metadata endpoint.
src/
app/
page.tsx the book — grid, search, tag filter
add/ capture → read → crop → form
recipe/[id]/ one recipe, and its edit form
books/, book/[id]/ the shelf, and everything cooked from one book
login/ passcode gate
api/
extract/photo/ Claude reads photographed pages, and finds the dish
extract/url/ schema.org first, Claude on the page text otherwise
extract/text/ Claude reads a pasted caption
photos/ upload, serve, and turn pictures
books/ the shelf
recipes/ CRUD
auth/ login and lock
components/
AddRecipeFlow.tsx the capture flow and its fallbacks
CropAdjuster.tsx drag and resize the proposed crop
RecipeForm.tsx one form, shared by add and edit
BookPicker.tsx pick a book off the shelf, or add one with its cover
RecipeBody.tsx tickable ingredients and steps
ImageCarousel.tsx scroll-snap picture strip
lib/
extract.ts the shared prompt, schema and Claude call
fetch-page.ts fetching, schema.org parsing, image discovery
recipes.ts queries and input validation
books.ts the shelf, and matching a book you've logged before
photos.ts storing uploads and remote pictures
image.ts browser-side downscaling
taxonomy.ts source types and tags
auth.ts cookie signing
proxy.ts the passcode gate, in front of everything
scripts/
init-db.mjs schema
gen-icons.mjs draws the PWA icons
- Favourites per book. The shelf knows which recipes came from which book; marking a few per book as the ones worth going back to is a small addition on top of the tags that already exist.
- A "what can I cook?" view. The ingredients are already stored as lines per recipe; parsing quantities out of them would let the book answer "what can I make with what's in the fridge".
- Cook mode — keep the screen awake, one step at a time, big type.
- Scaling. Servings is free text today. Parsed quantities would let a recipe be doubled or halved in place.
- Server-side search. The
recipestable already carries a generatedtsvectorand a GIN index, andGET /api/recipes?q=uses it. The list page currently filters on the client instead, which is instant and works well for a personal collection; the server path is there for when it isn't.