Documentation
IntroductionArchitectureMonorepo & ToolingRunning Locally
Screen Flow MapUser JourneysPersonas & RolesWeb ScreensClient Portal
Convex OverviewData ModelFunctions ReferenceAuth & SessionsRoles & PermissionsJobs & Providers
Web Design SystemAdmin Console DesignPatterns & Conventions
Web AppiOS AppMarketing Website
Testing & HarnessCI Pipeline

Web App

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]/*

The staff shell: role-filtered sidebar with live badges, charcoal header with ⌘K search, location switcher, and the clock widget
The staff shell: role-filtered sidebar with live badges, charcoal header with ⌘K search, location switcher, and the clock widget

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): a NAV array of {label, href, icon, min, badge?} filtered by roleAtLeast(myRole, min) — role comes from the orgs.shell query. Active = prefix match → bg-brand-soft/70 text-brand. Live badge counts (notes, messages) from shell.badges as red pills. Org logo or the PawMark SVG. Footer links: Customer portal, and Platform admin for platform owners.
  • Header (bg-charcoal text-white h-14, sticky): global search trigger (⌘K → GlobalSearch modal querying search.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, toggles timeclock.clockIn/clockOut; turns success-green while on the clock with H:MM elapsed.
  • LocationSwitcher: LocationFilterProvider context persists locationId per 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:

  1. Error boundaries (error.tsx at root and org level) decode authErrorCode(error): UNAUTHENTICATED → /login redirect, FORBIDDEN → ForbiddenScreen, ORG_NOT_FOUND → NotFoundScreen, anything else → ErrorScreen with retry.
  2. Null-result 404s: detail queries return null for missing/foreign ids → pages render NotFoundScreen resource="client" etc.
  3. 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

Page component
useOrgQuery / useOrgMutation
passes domain args only
Wrapper injects
{ sessionToken, orgSlug }
resolveOrgContext
session → org → membership
typed errors on failure
ctx gains
{ user, org, membership }
Feature handler
requirePolicy("area.action") → reads ctx.org
db writes + logAudit
result / reactive push to every subscriber
Feature code never filters by orgId — reading ctx.org is the isolation guarantee, and the authz suite proves cross-tenant ids resolve to null.
Life of an org-scoped call — tenancy is resolved once, in the wrapper, never in feature code

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.