Capture recipes from anywhere on your phone, let AI clean them up, and actually cook them. Built for a household or a few friends: each person logs in with their own password and gets their own private library. Next.js + Supabase + Anthropic + Vercel, installed on iOS as a home-screen web app.
What it does
See DESIGN.md for the visual/motion direction.
supabase/migrations/ in order (001 → 010), pasting
each one in full. 001 creates the tables, pgvector + trigram indexes, the
match_recipes RPC, and a public captures storage bucket; the rest layer on
ingredient canonicalization, users, per-user scoping, and later columns.insert into users (name, password, capture_key)
values ('Your name', 'a-long-password', replace(gen_random_uuid()::text, '-', ''))
returning capture_key;
Keep the returned capture_key — it's your key for the iOS Shortcut. Add more people
the same way.NEXT_PUBLIC_SUPABASE_URLservice_role secret → SUPABASE_SERVICE_ROLE_KEY (server-only; RLS is enabled with
no policies, so the public anon key can read nothing)Create an API key at console.anthropic.com → ANTHROPIC_API_KEY.
Model IDs live in one place: lib/config.ts
(claude-haiku-4-5 for extraction/tagging/categorization, claude-sonnet-5 for planning/chat).
Semantic search ("that soup with the coconut milk") uses embeddings. Anthropic's API has no
embeddings endpoint, so this uses Voyage AI (Anthropic's recommended embeddings partner) —
free tier is plenty for a personal library. Key from voyageai.com
→ VOYAGE_API_KEY. Without it, everything still works; search falls back to keyword matching.
cp .env.example .env.local # fill in every value (APP_PASSWORD + CAPTURE_API_KEY are yours to invent)
npm install
npm run dev
APP_PASSWORD isn't a login password — it's the server secret that signs login cookies,
so make it long and random. Log in with the password you gave your user row.
CAPTURE_API_KEY is legacy (only the /health page checks it); set it to anything.
Requires Node 22 (see .nvmrc).
Open http://localhost:3000 — you'll hit the password gate; log in with your user's password.
Set CAPTURE_KEY to your user's capture_key from step 1 first.
# URL capture — should return {"id":"...","status":"pending"} in well under a second
curl -s -X POST http://localhost:3000/api/capture \
-H "Content-Type: application/json" \
-H "x-api-key: $CAPTURE_KEY" \
-d '{"url": "https://cooking.nytimes.com/recipes/1017089-marcella-hazans-tomato-sauce"}'
# Text capture
curl -s -X POST http://localhost:3000/api/capture \
-H "Content-Type: application/json" \
-H "x-api-key: $CAPTURE_KEY" \
-d '{"text": "Garlic bread. 1 baguette, 4 cloves garlic, 100g butter. Mix, spread, bake 10 min at 200C."}'
# Screenshot capture (base64 images, in scroll order)
curl -s -X POST http://localhost:3000/api/capture \
-H "Content-Type: application/json" \
-H "x-api-key: $CAPTURE_KEY" \
-d "{\"images\": [\"$(base64 -i screenshot1.png)\", \"$(base64 -i screenshot2.png)\"]}"
Then open the Library — the skeleton card appears immediately and materializes when extraction finishes (a few seconds).
npx vercel
.env.example in the Vercel project settings.after() — on Vercel the
function stays alive post-response, so no queue infra is needed. maxDuration = 300
is set on the capture route; on the free Hobby plan functions cap lower, so if long
extractions get cut off, either upgrade or lower expectations for huge screenshot stacks.Create a new Shortcut named "Save recipe":
https://YOUR-APP.vercel.app/api/capturePOSTx-api-key = your user's capture_keyJSON → field url = Shortcut Input1200, height auto (keeps payloads under Vercel's limit)imgsJSON → field images = list imgs
(simpler alternative: build a "Text" body of {"images": ["...","..."]} with the
Combine Text action — whichever you find easier in the Shortcuts editor)pdf = encoded inputtext = Shortcut InputUsage: in Safari/Instagram/Reddit → Share → Save recipe. For a scrolling recipe, take your screenshots first (in order, overlapping is fine), then select them all in Photos → Share → Save recipe. Safari's Full Page screenshot → Save as PDF → Share → Save recipe also works and is often the best capture for long blog recipes.
The endpoint responds in under a second; extraction continues in the background and the recipe assembles itself in the app.
Async extraction: POST /api/capture inserts a pending recipe row + a
pending_captures retry row, responds immediately, then runs extraction in
after() (post-response). Failures mark the card failed with a readable error and a
one-tap Retry (POST /api/recipes/[id]/retry) that re-reads the original payload
from raw_capture / Storage — the original share is never lost.
Screenshots are sent to Claude as one message with all images in order so
overlapping scroll captures get deduplicated into a single recipe; model-reported
gaps surface as a quiet "might be incomplete" flag, never a failure.
Duplicate detection runs after extraction (Haiku over title + main ingredients),
sets possible_duplicate_of, and the UI shows a soft dismissible prompt. Never blocks,
never auto-merges.
Search blends pgvector cosine similarity (Voyage embeddings over title/description/main ingredients/notes) with keyword + ingredient-overlap hits.
Grocery merging happens server-side on add: same normalized name in the open trip → quantities sum when units match ("2 cloves" + "3 cloves" = "5 cloves"), sources are preserved on the item so you can see why it's on the list.
Section corrections persist in item_section_prefs by normalized item name and win
over AI categorization forever after.
Token spend for every AI call lands in the usage table
(select feature, sum(input_tokens), sum(output_tokens) from usage group by 1;).
URL extraction cache: extraction_cache keyed by URL hash — re-saving a link is free.
PWA + offline grocery list: install from Safari via Share → Add to Home Screen
(standalone, no browser chrome). The grocery list is local-first: state lives in
localStorage, every change applies instantly and enqueues a sync op, and a flush loop
replays the queue on reconnect (lib/useGrocery.ts). A service worker (public/sw.js,
production only) keeps the app shell openable with zero signal; API data is never
cached, so nothing stale renders. Items added offline land in "Other" and jump to their
real section when Haiku categorizes them on sync.
Ingredient canonicalization: Haiku normalizes on write ("scallions" and
"green onions" → green onion), cached forever in ingredient_aliases, stored as
canonical_name on each list item. Merging, section prefs, and (future) pantry
matching all key on the canonical name.
Undo, not confirmations: removing an item or archiving a trip just happens, with a
6-second undo toast. Archive-undo reopens the trip server-side
(POST /api/grocery/archive/undo); the archive sheet remains only as the carry-over picker.
Export: GET /api/export downloads the entire library (recipes, cook log, trips,
items, staples, prefs, aliases) as one JSON file. You're never trapped in the app.
Per-user silos: every owned table carries a user_id, and every query filters on
it in app code (the server uses the service-role key, which bypasses RLS). The login
cookie is signed with APP_PASSWORD, so it can't be edited to impersonate another
user. The iOS Shortcut's x-api-key maps to a user's capture_key.
TypeScript
94.9%
PLpgSQL
2.3%
CSS
2.2%
Capture recipes from anywhere on your phone, let AI clean them up, and actually cook them. Built for a household or a few friends: each person logs in with their own password and gets their own private library. Next.js + Supabase + Anthropic + Vercel, installed on iOS as a home-screen web app.
What it does
See DESIGN.md for the visual/motion direction.
supabase/migrations/ in order (001 → 010), pasting
each one in full. 001 creates the tables, pgvector + trigram indexes, the
match_recipes RPC, and a public captures storage bucket; the rest layer on
ingredient canonicalization, users, per-user scoping, and later columns.insert into users (name, password, capture_key)
values ('Your name', 'a-long-password', replace(gen_random_uuid()::text, '-', ''))
returning capture_key;
Keep the returned capture_key — it's your key for the iOS Shortcut. Add more people
the same way.NEXT_PUBLIC_SUPABASE_URLservice_role secret → SUPABASE_SERVICE_ROLE_KEY (server-only; RLS is enabled with
no policies, so the public anon key can read nothing)Create an API key at console.anthropic.com → ANTHROPIC_API_KEY.
Model IDs live in one place: lib/config.ts
(claude-haiku-4-5 for extraction/tagging/categorization, claude-sonnet-5 for planning/chat).
Semantic search ("that soup with the coconut milk") uses embeddings. Anthropic's API has no
embeddings endpoint, so this uses Voyage AI (Anthropic's recommended embeddings partner) —
free tier is plenty for a personal library. Key from voyageai.com
→ VOYAGE_API_KEY. Without it, everything still works; search falls back to keyword matching.
cp .env.example .env.local # fill in every value (APP_PASSWORD + CAPTURE_API_KEY are yours to invent)
npm install
npm run dev
APP_PASSWORD isn't a login password — it's the server secret that signs login cookies,
so make it long and random. Log in with the password you gave your user row.
CAPTURE_API_KEY is legacy (only the /health page checks it); set it to anything.
Requires Node 22 (see .nvmrc).
Open http://localhost:3000 — you'll hit the password gate; log in with your user's password.
Set CAPTURE_KEY to your user's capture_key from step 1 first.
# URL capture — should return {"id":"...","status":"pending"} in well under a second
curl -s -X POST http://localhost:3000/api/capture \
-H "Content-Type: application/json" \
-H "x-api-key: $CAPTURE_KEY" \
-d '{"url": "https://cooking.nytimes.com/recipes/1017089-marcella-hazans-tomato-sauce"}'
# Text capture
curl -s -X POST http://localhost:3000/api/capture \
-H "Content-Type: application/json" \
-H "x-api-key: $CAPTURE_KEY" \
-d '{"text": "Garlic bread. 1 baguette, 4 cloves garlic, 100g butter. Mix, spread, bake 10 min at 200C."}'
# Screenshot capture (base64 images, in scroll order)
curl -s -X POST http://localhost:3000/api/capture \
-H "Content-Type: application/json" \
-H "x-api-key: $CAPTURE_KEY" \
-d "{\"images\": [\"$(base64 -i screenshot1.png)\", \"$(base64 -i screenshot2.png)\"]}"
Then open the Library — the skeleton card appears immediately and materializes when extraction finishes (a few seconds).
npx vercel
.env.example in the Vercel project settings.after() — on Vercel the
function stays alive post-response, so no queue infra is needed. maxDuration = 300
is set on the capture route; on the free Hobby plan functions cap lower, so if long
extractions get cut off, either upgrade or lower expectations for huge screenshot stacks.Create a new Shortcut named "Save recipe":
https://YOUR-APP.vercel.app/api/capturePOSTx-api-key = your user's capture_keyJSON → field url = Shortcut Input1200, height auto (keeps payloads under Vercel's limit)imgsJSON → field images = list imgs
(simpler alternative: build a "Text" body of {"images": ["...","..."]} with the
Combine Text action — whichever you find easier in the Shortcuts editor)pdf = encoded inputtext = Shortcut InputUsage: in Safari/Instagram/Reddit → Share → Save recipe. For a scrolling recipe, take your screenshots first (in order, overlapping is fine), then select them all in Photos → Share → Save recipe. Safari's Full Page screenshot → Save as PDF → Share → Save recipe also works and is often the best capture for long blog recipes.
The endpoint responds in under a second; extraction continues in the background and the recipe assembles itself in the app.
Async extraction: POST /api/capture inserts a pending recipe row + a
pending_captures retry row, responds immediately, then runs extraction in
after() (post-response). Failures mark the card failed with a readable error and a
one-tap Retry (POST /api/recipes/[id]/retry) that re-reads the original payload
from raw_capture / Storage — the original share is never lost.
Screenshots are sent to Claude as one message with all images in order so
overlapping scroll captures get deduplicated into a single recipe; model-reported
gaps surface as a quiet "might be incomplete" flag, never a failure.
Duplicate detection runs after extraction (Haiku over title + main ingredients),
sets possible_duplicate_of, and the UI shows a soft dismissible prompt. Never blocks,
never auto-merges.
Search blends pgvector cosine similarity (Voyage embeddings over title/description/main ingredients/notes) with keyword + ingredient-overlap hits.
Grocery merging happens server-side on add: same normalized name in the open trip → quantities sum when units match ("2 cloves" + "3 cloves" = "5 cloves"), sources are preserved on the item so you can see why it's on the list.
Section corrections persist in item_section_prefs by normalized item name and win
over AI categorization forever after.
Token spend for every AI call lands in the usage table
(select feature, sum(input_tokens), sum(output_tokens) from usage group by 1;).
URL extraction cache: extraction_cache keyed by URL hash — re-saving a link is free.
PWA + offline grocery list: install from Safari via Share → Add to Home Screen
(standalone, no browser chrome). The grocery list is local-first: state lives in
localStorage, every change applies instantly and enqueues a sync op, and a flush loop
replays the queue on reconnect (lib/useGrocery.ts). A service worker (public/sw.js,
production only) keeps the app shell openable with zero signal; API data is never
cached, so nothing stale renders. Items added offline land in "Other" and jump to their
real section when Haiku categorizes them on sync.
Ingredient canonicalization: Haiku normalizes on write ("scallions" and
"green onions" → green onion), cached forever in ingredient_aliases, stored as
canonical_name on each list item. Merging, section prefs, and (future) pantry
matching all key on the canonical name.
Undo, not confirmations: removing an item or archiving a trip just happens, with a
6-second undo toast. Archive-undo reopens the trip server-side
(POST /api/grocery/archive/undo); the archive sheet remains only as the carry-over picker.
Export: GET /api/export downloads the entire library (recipes, cook log, trips,
items, staples, prefs, aliases) as one JSON file. You're never trapped in the app.
Per-user silos: every owned table carries a user_id, and every query filters on
it in app code (the server uses the service-role key, which bypasses RLS). The login
cookie is signed with APP_PASSWORD, so it can't be edited to impersonate another
user. The iOS Shortcut's x-api-key maps to a user's capture_key.
TypeScript
94.9%
PLpgSQL
2.3%
CSS
2.2%