Web App Architecture
apps/web (@pawdentity/web) — the product. Next.js 16 App Router serving three surfaces from one codebase: the staff app, the customer portal, and the platform-admin landlord surface. Dev port 3200.
Stack
Next.js 16 (App Router) · React 19 · Tailwind 4 (CSS-first) · Convex React client · @pawdentity/domain for shared vocabulary · sonner · lucide-react · date-fns · CVA + clsx + tailwind-merge.
Notably absent by design: React Query/SWR/Redux (Convex live queries are the state layer), server-side data fetching (every page is a client component), and any REST layer.
Provider stack
src/components/providers.tsx: ConvexProvider (client from NEXT_PUBLIC_CONVEX_URL, default http://127.0.0.1:3210) → SessionProvider (token in localStorage paw_session_token) → sonner <Toaster position="top-right" richColors />.
The three surfaces
1. Staff app — /o/[slug]/*

Wrapped by OrgLayout (src/app/o/[slug]/layout.tsx):
- Auth gate:
useRequireAuth()redirects tokenless visitors to/login; renders nothing until session state is ready. - Sidebar (fixed,
w-56): aNAVarray of{label, href, icon, min, badge?}filtered byroleAtLeast(myRole, min)— role comes from theorgs.shellquery. Active = prefix match →bg-brand-soft/70 text-brand. Live badge counts (notes, messages) fromshell.badgesas red pills. Org logo or thePawMarkSVG. Footer links: Customer portal, and Platform admin for platform owners. - Header (
bg-charcoal text-white h-14, sticky): global search trigger (⌘K →GlobalSearchmodal queryingsearch.global, jumping to clients/pets),LocationSwitcher,ClockWidget, user menu (Switch account →/accounts, My hours →staff/me/hours, Sign out). - ClockWidget: reads
shell.clockedInAt, ticks every 30s, togglestimeclock.clockIn/clockOut; turns success-green while on the clock withH:MMelapsed. - LocationSwitcher:
LocationFilterProvidercontext persistslocationIdper org (localStorage: paw_location_{slug}); consumed by dashboard, board, calendar, schedule. Hidden entirely with fewer than 2 active locations;null= all locations.
2. Customer portal — /o/[slug]/portal
The layout short-circuits: when the path includes /portal it returns bare children — no sidebar, no charcoal header. See Client Portal.
3. Platform admin — /platform
Standalone page gated by platform.amIPlatformOwner; non-owners get ForbiddenScreen. Tenant application review with approve/reject actions.
Error architecture
Three cooperating layers:
- Error boundaries (
error.tsxat root and org level) decodeauthErrorCode(error):UNAUTHENTICATED→/loginredirect,FORBIDDEN→ForbiddenScreen,ORG_NOT_FOUND→NotFoundScreen, anything else →ErrorScreenwith retry. - Null-result 404s: detail queries return
nullfor missing/foreign ids → pages renderNotFoundScreen resource="client"etc. - UX role gates that the backend independently re-enforces — the UI hides what you can't do; the server refuses it regardless.
Data flow in one picture
No cache invalidation exists anywhere — Convex's reactivity replaces it. This is why the facility board, badges, and threads are live across sessions for free.
File organization
src/
├── app/ routes (all "use client")
│ ├── login/ accounts/ apply/ our-customers/ platform/
│ └── o/[slug]/ layout.tsx (the shell) + 30 org screens
├── components/
│ ├── ui/ button, badge, card, input, table, misc, route-status
│ ├── shell/ global-search, location-switcher
│ └── feature components: portal-sections, pet-care-sections,
│ payment-methods-card, documents-card, agreements-checklist, report-tools
└── lib/
├── session.tsx SessionProvider + useOrg* hooks
└── utils.ts cn() + formatters
Shared feature components live flat in src/components; only true primitives go in ui/.
Design system
Covered separately in Web App Design System: brand-red token palette, VisitStatusBadge/PaymentStatusBadge domain wrappers, the misc.tsx utility belt, and recurring class idioms.