Last Updated: 2026-02-07
Framework: Expo Router v6 + React Native 0.81 + NativeWind v4
Protocol Guide's frontend is a jurisdiction-aware EMS protocol search interface built with Expo (React Native for Web/iOS/Android). The core user flow: select your state and agency (LEMSA), then search — results are scoped to that jurisdiction's protocols. The frontend uses Expo Router for file-based navigation and NativeWind (Tailwind CSS for React Native) for styling.
Key characteristics:
- Mobile-first design optimized for field use by paramedics and EMTs
- Jurisdiction selection flow: State -> Agency (LEMSA) -> scoped protocol search
- Medical disclaimer required before protocol access
- EMS terminology in voice search (maps speech-to-text errors to correct terms)
- Dark/light theme support (defaults to dark)
- Offline-capable via service worker + AsyncStorage caching
- Voice search integration with EMS term recognition
- OAuth authentication (Google/Apple via Supabase)
The primary UI interaction is jurisdiction-aware search. Users must select their state and agency before searching:
┌────────────────────┐
│ StateModal │ User selects state (default: CA)
│ Shows states with │ Fetches via trpc.search.coverageByState
│ protocol counts │
└────────┬───────────┘
│
▼
┌────────────────────┐
│ AgencyModal │ User selects LEMSA/agency within state
│ Shows agencies for │ Fetches via trpc.search.agenciesByState
│ selected state, │ Sorted by protocol_count (most coverage first)
│ sorted by count │ "All Agencies" option for state-wide search
└────────┬───────────┘
│
▼
┌────────────────────┐
│ Search Input │ Query scoped to selected agency
│ Type or voice │ Backend: county → agency_id → vector search
│ │ WHERE agency_id = user's selection
└────────────────────┘
| Component | File | Purpose |
|---|---|---|
StateModal |
app/(tabs)/index.tsx |
State picker — lists states with protocol counts |
AgencyModal |
app/(tabs)/index.tsx |
Agency/LEMSA picker — lists agencies for selected state |
FilterRow |
app/(tabs)/index.tsx |
Displays current state + agency filter chips |
useFilterState |
app/(tabs)/index.tsx |
Hook managing state/agency filter state |
useCountyRestriction |
hooks/ |
Enforces tier limits: free = 1 county, pro = unlimited |
| Tier | County/Agency Limit | Behavior |
|---|---|---|
| Free | 1 county/agency | Shows upgrade modal when switching |
| Pro | Unlimited | Free switching between jurisdictions |
| Enterprise | Unlimited | Free switching + admin features |
A medical disclaimer modal (DisclaimerConsentModal) blocks protocol access until acknowledged. This is legally required — Protocol Guide returns clinical information that could affect patient care.
- Disclaimer must be acknowledged before first search
- Acknowledgment timestamp stored as
disclaimerAcknowledgedAt - Key message: "YOUR LOCAL PROTOCOLS ALWAYS TAKE PRECEDENCE"
Protocol-Guide/
├── app/ # Expo Router pages (file-based routing)
│ ├── _layout.tsx # Root layout (providers, global setup)
│ ├── index.tsx # Landing page (unauthenticated)
│ ├── (tabs)/ # Tab-based navigation (main app)
│ │ ├── _layout.tsx # Tab bar configuration
│ │ ├── index.tsx # Search screen (home)
│ │ ├── calculator.tsx # Dosing calculator
│ │ ├── profile.tsx # User profile/settings
│ │ ├── coverage.tsx # (hidden) Coverage map
│ │ ├── history.tsx # (hidden) Search history
│ │ └── search.tsx # (hidden) Alternative search
│ ├── admin/ # Agency admin dashboard
│ │ ├── _layout.tsx # Sidebar navigation layout
│ │ ├── index.tsx # Admin dashboard
│ │ ├── analytics/ # Analytics views
│ │ ├── protocols/ # Protocol management
│ │ ├── settings/ # Billing, agency settings
│ │ ├── team/ # Team management
│ │ └── users/ # User management
│ ├── tools/ # Standalone EMS tools
│ │ ├── _layout.tsx # Stack navigation
│ │ ├── arrest-timer.tsx # Cardiac arrest timer
│ │ ├── dosing-calculator.tsx
│ │ └── rosc-checklist.tsx
│ ├── oauth/callback.tsx # OAuth redirect handler
│ ├── contact.tsx # Contact page
│ ├── disclaimer.tsx # Medical disclaimer
│ ├── feedback.tsx # User feedback form
│ ├── login.tsx # Login page
│ ├── privacy.tsx # Privacy policy
│ └── terms.tsx # Terms of service
│
├── components/ # Reusable UI components
│ ├── ui/ # Primitive UI components
│ │ ├── collapsible.tsx
│ │ ├── icon-symbol.tsx # SF Symbols wrapper
│ │ ├── Modal.tsx
│ │ └── Skeleton.tsx # Loading skeletons
│ ├── search/ # Search-specific components
│ │ ├── AgencyModal.tsx
│ │ ├── EmptySearchState.tsx
│ │ ├── FilterRow.tsx
│ │ ├── MessageBubble.tsx
│ │ ├── SearchHeader.tsx
│ │ ├── SearchLoadingSkeleton.tsx
│ │ ├── StateModal.tsx
│ │ ├── SummaryCard.tsx
│ │ └── VoiceErrorBanner.tsx
│ ├── landing/ # Landing page sections
│ │ ├── hero-section.tsx
│ │ ├── features-section.tsx
│ │ ├── simulation-section.tsx
│ │ ├── email-capture-section.tsx
│ │ └── footer-section.tsx
│ ├── seo/ # SEO components (meta, structured data)
│ │ ├── FAQSection.tsx
│ │ ├── SEOHead.tsx
│ │ └── StructuredData.tsx
│ ├── arrest-timer/ # Cardiac arrest timer
│ ├── pediatric-dosing-calculator/
│ ├── rosc-checklist/ # Post-ROSC bundle
│ ├── referral/ # Referral system UI
│ ├── voice/ # Voice search UI
│ ├── icons/ # Custom SVG icons
│ ├── ErrorBoundary.tsx # Error boundaries
│ ├── screen-container.tsx # SafeArea wrapper
│ ├── chat-input.tsx # Search input
│ └── ... # 30+ more components
│
├── hooks/ # Custom React hooks
│ ├── use-auth.ts # Supabase auth state
│ ├── use-colors.ts # Theme colors
│ ├── use-protocol-search.ts # Search logic
│ ├── use-voice-search.ts # Voice input
│ ├── use-filter-state.ts # Search filters
│ ├── use-disclaimer.ts # Disclaimer consent
│ ├── use-favorites.ts # Saved protocols
│ ├── use-offline-cache.ts # Offline storage
│ └── ... # 10+ more hooks
│
├── lib/ # Core utilities
│ ├── _core/
│ │ ├── theme.ts # Theme system core
│ │ └── nativewind-pressable.ts
│ ├── analytics/ # Event tracking
│ ├── accessibility/ # A11y utilities
│ ├── trpc.ts # tRPC client setup
│ ├── supabase.ts # Supabase client
│ ├── auth-context.tsx # Auth provider
│ ├── app-context.tsx # App state provider
│ ├── theme-provider.tsx # Theme provider
│ ├── query-client.ts # React Query config
│ ├── offline-cache.ts # Offline storage
│ ├── haptics.ts # Haptic feedback
│ └── ... # 20+ more utilities
│
├── constants/ # App constants
│ ├── theme.ts # Theme re-exports
│ ├── oauth.ts # OAuth configuration
│ └── const.ts # General constants
│
├── types/ # TypeScript types
│ └── search.types.ts # Search-related types
│
├── utils/ # Helper utilities
│ ├── protocol-helpers.ts
│ └── search-formatters.ts
│
├── assets/ # Static assets
│ └── images/
│
├── public/ # Web static files
│
└── global.css # Global CSS (Tailwind base)
Protocol Guide uses Expo Router v6 with file-based routing. Route structure maps directly to the app/ directory.
Route Groups:
(tabs)- Tab-based navigation (parentheses hide from URL)admin- Nested stack navigation with sidebartools- Stack navigation for standalone tools
Navigation Patterns:
// Programmatic navigation
import { useRouter } from "expo-router";
const router = useRouter();
router.push("/(tabs)"); // Navigate to tabs
router.push("/admin/analytics"); // Navigate to admin
router.replace("/login"); // Replace current screen
router.back(); // Go backRoot Layout (app/_layout.tsx):
export const unstable_settings = {
initialRouteName: "index", // Landing page for unauthenticated users
};
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="tools" />
<Stack.Screen name="oauth/callback" />
</Stack>Tabs Layout (app/(tabs)/_layout.tsx):
<Tabs screenOptions={{ headerShown: false }}>
<Tabs.Screen name="index" options={{ title: "Search" }} />
<Tabs.Screen name="calculator" options={{ title: "Dosing" }} />
<Tabs.Screen name="profile" options={{ title: "Profile" }} />
{/* Hidden tabs */}
<Tabs.Screen name="coverage" options={{ href: null }} />
<Tabs.Screen name="history" options={{ href: null }} />
</Tabs>| Route | Purpose | Auth Required |
|---|---|---|
/ |
Landing page with marketing content | No |
/(tabs) |
Main search interface | No (anonymous browsing) |
/(tabs)/calculator |
Medication dosing calculator | No |
/(tabs)/profile |
User profile, settings, subscription | Yes for full features |
/admin |
Agency admin dashboard | Yes (agency admin role) |
/tools/arrest-timer |
Cardiac arrest timer (Protocol 1210) | No |
/tools/dosing-calculator |
Pediatric dosing calculator | No |
/tools/rosc-checklist |
Post-ROSC bundle checklist | No |
/disclaimer |
Medical disclaimer | No |
/privacy |
Privacy policy | No |
/terms |
Terms of service | No |
<ThemeProvider>
<SafeAreaProvider>
<GestureHandlerRootView>
<trpc.Provider>
<QueryClientProvider>
<AuthProvider>
<AppProvider>
{/* App content */}
</AppProvider>
</AuthProvider>
</QueryClientProvider>
</trpc.Provider>
</GestureHandlerRootView>
</SafeAreaProvider>
</ThemeProvider>| State Type | Solution | Location |
|---|---|---|
| Server state | React Query + tRPC | lib/trpc.ts, lib/query-client.ts |
| Auth state | Context + Supabase | lib/auth-context.tsx |
| App state | Context | lib/app-context.tsx |
| Theme | Context + localStorage | lib/theme-provider.tsx |
| Form state | Local useState | Per-component |
| Search filters | Custom hook | hooks/use-filter-state.ts |
| Offline cache | AsyncStorage | lib/offline-cache.ts |
| Favorites | AsyncStorage | hooks/use-favorites.ts |
Type-safe API calls via tRPC v11:
// Query
const { data, isLoading } = trpc.user.usage.useQuery();
// Mutation
const mutation = trpc.subscription.createPortal.useMutation();
await mutation.mutateAsync({ returnUrl: window.location.href });
// With React Query options
const { data } = trpc.agencyAdmin.myAgencies.useQuery(undefined, {
enabled: isAuthenticated,
staleTime: 5 * 60 * 1000,
});Auth Context:
const { user, isAuthenticated, loading, logout } = useAuthContext();App Context:
const { selectedCounty, setSelectedCounty, messages, addMessage } = useAppContext();Theme Context:
const { colorScheme, toggleTheme, setThemePreference } = useThemeContext();Protocol Guide uses NativeWind v4 - a universal styling solution that compiles Tailwind CSS to React Native StyleSheet.
Configuration:
// tailwind.config.js
module.exports = {
darkMode: "class",
content: ["./app/**/*.{js,ts,tsx}", "./components/**/*.{js,ts,tsx}", ...],
presets: [require("nativewind/preset")],
theme: {
extend: {
colors: {
primary: { DEFAULT: "var(--color-primary)", light: "...", dark: "..." },
// ... semantic colors from theme.config.js
},
},
},
};Usage:
// NativeWind classes
<View className="flex-1 bg-background p-4">
<Text className="text-xl font-bold text-foreground">Title</Text>
</View>
// Combined with style prop
<View className="flex-1" style={{ gap: 12 }}>Color Tokens (semantic):
primary- Brand red (#A31621)background- Page backgroundsurface- Card backgroundsforeground- Primary textmuted- Secondary textborder- Borderssuccess,warning,error- Status colors
Theme Switching:
// CSS variables set on :root
:root[data-theme="dark"] {
--color-background: #0F172A;
--color-foreground: #F8FAFC;
}
:root:not([data-theme="dark"]) {
--color-background: #FFFFFF;
--color-foreground: #0F172A;
}For complex or performance-critical styles:
const styles = StyleSheet.create({
container: {
flex: 1,
flexDirection: "row",
},
sidebar: {
width: 240,
borderRightWidth: 1,
},
});// lib/design-tokens.ts
export const spacing = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 };
export const radii = { sm: 4, md: 8, lg: 12, xl: 16 };
export const touchTargets = { min: 44, comfortable: 48, large: 56 };All components are client-side. Expo/React Native doesn't have server components. However, the app is optimized for:
- Lazy loading below-the-fold sections
- Suspense boundaries for code splitting
- Server-side data fetching via tRPC
// Lazy load below-fold sections
const SimulationSection = lazy(() => import("@/components/landing/simulation-section"));
<Suspense fallback={<SectionPlaceholder />}>
<SimulationSection />
</Suspense>All screens wrap content in ScreenContainer for consistent SafeArea handling:
<ScreenContainer edges={["top", "left", "right"]} className="px-4">
{/* Screen content */}
</ScreenContainer>Multiple error boundaries for granular error handling:
<ErrorBoundary section="general" errorTitle="Profile Error">
<ProfileContent />
</ErrorBoundary>
<SearchResultsErrorBoundary>
<FlatList data={messages} />
</SearchResultsErrorBoundary>Skeleton components for loading states:
if (isLoading) {
return (
<ScrollView>
<SkeletonProfileHeader />
<SkeletonSubscriptionCard />
<SkeletonRecentQueries count={3} />
</ScrollView>
);
}Consistent modal implementation:
<Modal
visible={showLogoutModal}
onDismiss={() => setShowLogoutModal(false)}
title="Sign Out"
message="Are you sure?"
variant="confirm"
buttons={[
{ label: "Cancel", onPress: handleCancel, variant: "secondary" },
{ label: "Sign Out", onPress: confirmLogout, variant: "destructive" },
]}
/>const { voiceError, handleVoiceError, clearVoiceError } = useVoiceSearch();
<VoiceSearchButton
onTranscription={(text) => handleSendMessage(text)}
onError={handleVoiceError}
disabled={isLoading}
size="medium"
/>- Service Worker registration for PWA
- AsyncStorage for protocol caching
- OfflineBanner component for connectivity status
const { cacheSize, clearCache, itemCount } = useOfflineCache();- User clicks "Sign in with Google/Apple"
- Supabase OAuth redirects to provider
- Provider redirects to
/oauth/callback - App extracts tokens, stores session
AuthProviderupdates global auth state
// OAuth initiation
await signInWithGoogle();
await signInWithApple();
// Auth state access
const { user, isAuthenticated, logout } = useAuthContext();import * as Haptics from "@/lib/haptics";
// On button press
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// On success/error
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);Dynamic meta tags and structured data for web:
<SEOHead
title="EMS Protocol Search"
description="AI-powered EMS protocol search..."
path="/"
keywords={["LA County EMS protocols", ...]}
/>
<OrganizationSchema />
<WebSiteSchema />
<MedicalWebPageSchema
name={seoTitle}
medicalAudience={["Paramedics", "EMTs"]}
specialty="Emergency Medicine"
/>- Lazy loading - Below-fold components loaded on demand
- Memoization - useMemo/useCallback for expensive computations
- React Query caching - Aggressive cache times for EMS field use
- Image optimization - WebP format, lazy loading
- Bundle splitting - Expo's automatic code splitting
- Skeleton UI - Instant perceived loading
# Start development (server + metro bundler)
pnpm dev
# Metro only (web)
pnpm dev:metro
# Build for web
pnpm build:web
# Type checking
pnpm check
# Linting
pnpm lint
# Testing
pnpm test
pnpm test:e2e- API Documentation - Backend API reference
- Database Architecture - Schema details
- Deployment - Deployment procedures
- Security - Security considerations