kham.udom work the-recipe-room

The Recipe Room — recipe management system

Next.js 15TypeScriptSupabaseOpenAIReact QueryProgressive Web App
View site ↗ View source ↗

Why I built it. I love cooking, and I collect recipe ideas everywhere: web pages, social media, cookbook photos. My "system" was bookmarks and screenshots, which meant scrolling through hundreds of images every time I wanted to cook something again. The Recipe Room replaces that with one searchable place: photograph any recipe and AI extracts it into structured, editable data. It's an ongoing project, and it doubles as my proving ground for owning a system end to end: schema, API, auth, AI pipeline, and UI.

case.architecture — the whole system

System architecture

Three trust zones: the browser, my server code, and third-party services. The client never talks to OpenAI directly and never holds a privileged database key. AI calls, image uploads, and search are brokered by API routes on the server. Recipe reads and writes go from the browser to Supabase directly with the public anon key, and Postgres Row Level Security does the authorization: write policies scope rows to the signed-in user, and a few read policies expose featured recipes publicly.

zone: browser zone: server (Vercel) zone: managed services client — installable pwa Next.js 15 · App Router React Query · persisted cache Redux Toolkit · ui state CSS Modules · design tokens service worker · offline Swiper · Lottie preload holds no secrets middleware session check route guard redirects api routes /api/analyze-recipe /api/chef /api/extract-recipe-url /api/upload-image /api/recipes · search /api/category-counts validated JSON · errors supabase Auth · JWT sessions Postgres + RLS owner-scoped policies Storage · recipe images openai gpt-4o · photo → recipe gpt-4o · url → recipe gpt-4o · Chef Gusto chat https storage · search prompt + image supabase-js · auth + recipe CRUD · RLS-enforced
fig 1 — secrets live server-side, and ownership is enforced by Row Level Security in the database, where a client bug cannot bypass it.

case.flow — the interesting path

Request flow: photo in, recipe out

The signature feature is turning a photo of any recipe, a cookbook page or a handwritten card, into structured data. Here's the full round trip:

  1. Upload. The user photographs a recipe. The client compresses and posts it to /api/analyze-recipe, never to OpenAI directly, so the API key stays server-side.
  2. Extract. The route sends the image to gpt-4o with a prompt that demands a strict JSON shape: title, ingredients, instructions, prep and cook time, servings, category.
  3. Validate. The response is parsed and validated against the Recipe type before it ever reaches the client. Malformed AI output fails gracefully with an actionable error, not a broken form.
  4. Review. The extracted recipe pre-fills the editing form. AI output is a draft, not a commit. The user confirms and corrects before anything is saved.
  5. Persist. On save, the recipe is written to Postgres straight from the browser with supabase-js. The Row Level Security policy checks ownership in the database, so client code cannot write into someone else's collection.

case.decisions — tradeoffs I made on purpose

Architecture decisions

decision.security

Authorization lives in the database

Recipe reads and writes go straight from the browser with supabase-js, so Row Level Security is the authorization layer. Write policies scope rows to the signed-in user, and a few read policies deliberately expose featured recipes to everyone. Middleware only guards protected pages. The tradeoff is writing and testing SQL policies alongside app code, but a policy in Postgres cannot be bypassed by a bug in my UI, and it removed an entire tier of CRUD endpoints I would otherwise have to build and maintain.

decision.state

Server state is a cache, not a store

Recipes are server-owned data, so they live in React Query, with the cache persisted to storage so the app opens with recipes instead of spinners. Mutations write through on success and invalidate their queries, which keeps the cache honest without optimistic bookkeeping. Redux Toolkit handles what is genuinely client state: the search controls and the chef widget. Two tools, each doing the job it was built for.

decision.ai

AI behind an API, treated as untrusted input

Three routes, photo analysis, URL extraction, and the chef chat, all call gpt-4o server-side, so the API key never reaches the browser. Model output is validated like any user input and lands in a review step before persisting: useful when it works, harmless when it hallucinates. And because the client only knows the routes, swapping the model never touches the UI.

case.frontend — where the ux engineering shows

Frontend architecture

The UI is a vintage cookbook: warm cream, classic recipe cards, and handwritten-feeling type, built on a token layer in design-system/tokens so the theme is data, not scattered CSS. I also put focused work into how fast it feels: skeleton loaders match the final layout so nothing shifts, Lottie animations are preloaded, images run through Next.js optimization, and routes code-split on their own. Offline, the service worker keeps the app usable in the kitchen, which is where a recipe app actually gets used, on a flour-covered phone with one hand.

case.transfer — what this work makes me good at

What transfers

I can take a product from database schema to shipped UI on my own. I give model output the same scrutiny as user input. Everything the AI returns is validated and reviewed before it's saved. I put the security checks in the database, where they still hold if my application code has a bug. And I pay close attention to how fast an app feels, which is a different problem from how fast it benchmarks.

This is the site's live theme editor. Each control below is a design token: a named style decision, like the accent color or corner radius, that every part of this page derives from. Change one and watch the whole system follow.

Reset to defaults

This drawer is a ch-dialog, the radios are ch-radio, and every button, card, and badge on this page is a Charm component. The sliders mutate Charm's token layer directly, and the system re-themes itself live. Under the hood, each token is a CSS custom property on :root, and your changes are saved in this browser. Go ahead, break it.