OnRotation is a weekly meal planning app built with Phoenix and LiveView. I’ve written about the product story before; this post focuses on the technical architecture: how the app is structured, how data flows, and the patterns we rely on to keep it simple and maintainable.
Tech Stack
- Phoenix 1.8 with LiveView
- Ecto + PostgreSQL for persistence
- Tailwind CSS v4 for styling (CSS variables, no config file)
- Req for HTTP (no HTTPoison/Tesla)
- Resend for transactional email (magic links, invites)
- Sentry for error tracking
Quality tooling: Credo, Dialyzer, SoBelow (security), all wired into mix precommit.
Architecture Overview
The app follows a context-based structure. Core domains:
- Accounts – users, households, invites, magic-link auth
- Meals – meal library, curated meals, meal packs
- Plans – weekly plans and plan entries
- ShoppingLists – per-household shopping list with plan-derived items
- Blog – public blog with Earmark-based body processing
Almost all domain data is scoped by household. A user belongs to one household; meals, plans, and shopping lists are all keyed by household_id. That keeps queries straightforward and authorization simple.
The Scope Pattern
We use an OnRotation.Accounts.Scope struct to represent the caller’s context:
defstruct user: nil, household: nil
phx.gen.auth gives us current_scope, not current_user. The scope carries both user and household, so controllers and LiveViews can consistently pass current_scope into contexts. Context functions take scope or household_id as the first argument and scope all queries by household.
This avoids leaking user-only logic into contexts and keeps “who is acting” and “on which household” explicit.
Routing and Pipelines
We use separate pipelines and live sessions for different auth levels:
- Public – marketing, blog, sitemap, robots (browser_and_seo)
- Auth routes – log-in (magic link), registration
- Authenticated – plan, meals, shopping list, feedback (live_session :authenticated)
- Blog admin – create/edit blog posts (require_blog_author)
- Meal pack admin – manage curated meal packs (require_meal_pack_admin)
- Admin – metrics dashboard (require_admin)
The API pipeline (/api/v1) accepts JSON and uses Bearer token auth. It’s used by the Android app; web users stay in LiveView.
Authentication
We’re privacy-focused: no passwords, magic links only, and Simple Analytics for basic analytics instead of tracking-heavy alternatives. No cross-site tracking, no ad networks.
Web users sign in via magic links. The flow:
- User enters email.
- We create or find the user and send a tokenized link (Resend).
- User clicks the link; we validate the token and establish a session.
For the mobile API:
- Client calls
POST /api/v1/auth/magic-linkwith email. - User receives the link and opens it in a browser; we redirect to a page that calls the API to exchange the token for a Bearer token.
- Client stores the Bearer token and sends it on subsequent requests.
Both flows end up with a session token; web uses cookies, API uses Authorization: Bearer <token>.
Data Model
The main entities:
- Household – one per account; owns meals, plans, shopping list
- Meal – name, recipe_url, main_ingredients, extras, tags, complexity (1–3)
- Plan – one per week per household (
week_start_date= Monday) - PlanEntry – links plan to meal, with
day_index(0=Mon … 6=Sun) and position for multiple meals per day - ShoppingListEntry – per-household; entries come from plan merge or manual “extras”
Meals, plans, and shopping lists are always queried with household_id in the WHERE clause.
Shopping List: Plan Merge and Categorization
The shopping list has two sources of items:
- Plan-derived – parsed from
main_ingredientsandextrasof meals in the current week’s plan - Manual extras – user-added items (e.g. paper towels, milk)
merge_plan_into_list/2 takes the current week’s plan, parses ingredients into segments (comma/semicolon), normalizes them into item_keys for grouping, and upserts into ShoppingListEntry by (household_id, item_key). Existing rows are never deleted by the merge—only added or updated.
Categorization is done by ShoppingLists.Categorizer: whole-word keyword matching against produce, meat/seafood, cheese/dairy, pantry, and a default “items” bucket. Priority order matters (e.g. “egg noodles” matches pantry before cheese) so we can sort the list in a sensible shopping order.
LiveView and UI
Core features (plan, meals, shopping list) are LiveView pages. We use:
- Streams for large, appendable lists to avoid memory growth
- Functional components (CoreComponents) for forms, buttons, layout
- Colocated JS hooks when we need client behavior (e.g. theme toggle in localStorage)
All LiveView templates start with <Layouts.app flash={@flash} ...> and receive current_scope from the live_session so they can pass it into context calls.
Design System
Colors and typography live in DESIGN.md and CSS variables (var(--onrotation-primary), etc.). Light/dark themes use data-theme="light" and data-theme="dark"; we also support “system” via prefers-color-scheme. Theme choice is persisted in localStorage and applied before paint when possible to avoid flash.
Transactional emails share the same layout and brand colors, with inline styles for email client compatibility.
Blog and Content
The blog is a separate context (OnRotation.Blog) with published posts, tags, and an author-scoped admin. Body content is stored as Markdown in the database and rendered with Earmark at request time. A BodyProcessor runs after Earmark to replace {{meal:N}} shortcodes with rendered meal cards—so authors can embed curated meals in posts, and logged-in users can add them to their library in place.
The blog admin is a LiveView (BlogAdminLive) behind a require_blog_author plug. Authors get a list view, create/edit with autosave draft, tag management, and a preview that matches the public layout. Public routes are controller-based: index (optionally filtered by tag), show by slug, and an RSS feed at /blog/feed.xml. The sitemap includes blog posts for SEO. Slugs are derived from titles; tags are many-to-many and filterable on the index.
API for Mobile
/api/v1 exposes resources for meals, plans, and plan entries. The API uses the same context functions as the web app; the only difference is auth (Bearer tokens) and response format (JSON). A plug assigns household_id from the authenticated user so controllers can call contexts like Meals.list_meals(household_id, opts) without extra logic.
Quality and Consistency
We enforce consistency with:
- One
aliasper line – noalias Foo.{Bar, Baz} @moduledocand@specon public functions- No
maybe_prefix – use descriptive names instead mix precommit– format, Credo, SoBelow, Dialyzer, tests
The AGENTS.md and .cursor/rules files document these conventions for humans and AI-assisted workflows.
Summary
OnRotation is a Phoenix app where:
- Household-scoped contexts and a
Scopestruct keep authorization and data access clear. - LiveView drives the web UX with streams and functional components.
- A small JSON API under
/api/v1serves mobile with the same business logic. - Magic links power both web and mobile auth.
- A token-based design system and precommit checks keep the codebase consistent and maintainable.
If you’re building something similar—household/multi-tenant, LiveView-heavy, with a mobile API—this architecture is a solid starting point. The main lesson: invest in scoping and context boundaries early; they make everything else easier.
Try OnRotation at onrotation.app — start with our Starter Pack and plan your week in minutes.