The fastest way to find a blood donor.
A production-grade, real-time blood donation network. Recipients post requests in seconds. Compatible donors nearby are notified instantly. Lives are saved on the same map, in the same minute.
Live API · Issues · Discussions · Roadmap
Every two seconds, someone somewhere needs blood. In emergencies, the bottleneck isn't supply — it's the time it takes to find a compatible donor nearby. Hospitals call relatives. Relatives forward WhatsApp messages. Messages spread to people who can't help. By the time a compatible donor sees the request, the window is closed.
BludStack flips that. The moment a recipient pins their hospital, our backend expands outward in geo-fenced rings — 1 km → 5 km → 15 km → 30 km → 50 km → country-wide — pushing real-time alerts only to compatible, eligible, available donors at each stage. When a donor accepts, the recipient sees their live GPS heartbeat on a map. Like Uber, but for the most important ride of someone's life.
- Highlights
- Architecture
- Tech stack
- Project structure
- The N-donors rule
- Geo-fence escalation algorithm
- Blood compatibility matrix
- Design system
- Performance budget
- Security model
- Backend setup
- Mobile setup
- Environment variables
- Deployment
- Cron jobs
- Push notifications
- API reference
- Database schema
- Roadmap
- FAQ
- Contributing
- License
- Author
- Real-time push escalation — compatible donors in widening geo-rings are paged in priority order until someone accepts. No SMS fan-out, no group chats, no time wasted on incompatibles.
- Atomic accept + complete RPCs —
accept_blood_requestandcomplete_blood_donationareSECURITY DEFINERPostgreSQL functions that enforce capacity, cooldown, age, and the N-donors rule in a single transaction. No race conditions, no double-accepts, no half-fulfilled state. - Live donor heartbeat — once a donor accepts, foreground GPS pushes their location to
/donations/heartbeat. The recipient sees the donor moving on a map in real time, Uber-driver style. - N units = N donors — a 5-unit request needs 5 distinct donors to accept and complete. One donor per unit. No counterfeit fulfilment.
- Row-level security everywhere — every table has RLS policies. Donors only see what they're allowed to see. Recipient phone numbers are never exposed until a donor commits.
- Killed-state notifications — push notifications wake the app from any state on Android and iOS via
expo-notificationswith per-OEM tuning (seePUSH_NOTIFICATIONS.md). - 120 FPS target on low-end Android — Reanimated v4 worklets, FlashList v2, memoized cells, expo-image with cache. Tested on Realme/Xiaomi mid-tier devices.
- Tri-state theme — system / dark / light. Crimson on warm onyx (dark) or warm bone (light). All colors flow through theme tokens. Zero hardcoded hex outside
Colors.ts. - Skeleton loading only — every loading state is a shimmer placeholder shaped like the content. No spinners, no
ActivityIndicator, no jarring pop-ins. - No emojis anywhere — Ionicons + custom SVG (
BrandMark) for every visual symbol. Period. - No external error monitoring —
errorReporteris a typed logger wrapper. No Sentry, no Bugsnag, no Crashlytics. Crash logs stay on-device or in your own server logs. - AsyncStorage everywhere — never
react-native-mmkv. Sensitive tokens go throughexpo-secure-store. Auth, query cache, KV all live in@react-native-async-storage/async-storage.
flowchart LR
subgraph Mobile["React Native / Expo SDK 54"]
UI["Screens · Reanimated v4 · FlashList v2"]
AuthCtx["AuthContext · ThemeContext · ToastContext"]
APIClient["utils/api.ts"]
SBClient["@supabase/supabase-js"]
Notif["expo-notifications"]
end
subgraph Vercel["Vercel Serverless (Express)"]
API["/auth · /profiles · /requests · /donations · /chat · /admin"]
Cron["Vercel Cron · escalation · expiry · cleanup"]
end
subgraph Supabase["Supabase"]
PG[("Postgres · RLS · RPCs")]
RT["Realtime · postgres_changes"]
Auth["Auth · OTP email"]
end
subgraph Push["Push"]
Expo["Expo Push API"]
APNs["APNs"]
FCM["FCM"]
end
UI --> AuthCtx --> APIClient
UI --> SBClient
APIClient -- "HTTPS · Bearer token" --> API
SBClient -- "Realtime WS" --> RT
SBClient -- "Auth" --> Auth
API -- "service_role" --> PG
Cron -- "/internal/cron" --> API
API -- "send push" --> Expo
Expo --> APNs
Expo --> FCM
Expo -- "delivered" --> Notif
Notif --> UI
supabase_schema.sql— the only schema file. Tables, enums, RLS, RPCs, grants, realtime publication.AuthContext— the only place the app readsprofilefrom. Updates merge the server-returned row (the backend may normalise — e.g., role downgrade on age fail — so the server response is canonical).ThemeContext— tri-state (system/dark/light) withAppearance.addChangeListenersubscription + dark default fallback (Expo Go Android limitation workaround).utils/api.ts— every HTTP call. Backend errors surface as typedApiErrordiscriminated byisApiError.
| Layer | Choice | Why |
|---|---|---|
| Mobile framework | Expo SDK 54 + RN 0.81.5 + React 19.1 | New Architecture default, first-class Reanimated v4, FlashList v2, expo-glass-effect. |
| Language | TypeScript (strict) | Catches every shape mismatch at compile time. |
| Navigation | Expo Router v6 | File-based, typed routes, group-aware guards via <Stack.Protected> semantics. |
| State | React Context (Auth, Theme, Toast) | Three contexts for an app this size; deliberately not Zustand (see REDESIGN_PLAN.md §13). |
| Server cache | Direct fetch + Supabase Realtime | Realtime is the cache-invalidation mechanism. TanStack Query intentionally not added. |
| Animations | Reanimated v4 worklets | UI-thread 120 FPS target. Spring/timing/sequence/repeat — every interactive animation. |
| Lists | @shopify/flash-list v2 |
maintainVisibleContentPosition.startRenderingFromBottom for chat; recycler for feeds. |
| Storage | @react-native-async-storage/async-storage + expo-secure-store |
Never MMKV. Sensitive tokens go through SecureStore. |
| Images | expo-image with cache policy |
Disk + memory cache, modern formats, faster than RN's Image. |
| Maps | react-native-maps |
Default provider with theme switching. |
| Backend | Node 20 + Express on Vercel Serverless | Stateless functions, free tier-friendly, sub-100 ms cold starts in regions. |
| Database | Supabase Postgres with RLS | Realtime subscriptions on postgres_changes. Atomic RPCs for accept/complete. |
| Auth | Supabase Auth · email OTP | No passwords. 6-digit code per session. |
| Notifications | expo-notifications + Expo Push API |
Killed-state delivery; per-OEM channels for Android (see PUSH_NOTIFICATIONS.md). |
| Geo | Haversine + deltaFromKm + reverse-geocode via expo-location |
All radius math in-app; reverse geocode hits the OS. |
BludStack/
├── mobile/ # Expo SDK 54 app
│ ├── app/
│ │ ├── _layout.tsx # ErrorBoundary > GH > KeyboardProvider > SafeArea > Theme > Auth > Toast > RootNavigator
│ │ ├── index.tsx # Cold-start gate (<Redirect>)
│ │ ├── (auth)/ # OTP sign-in (sheet UI + accent strip + handle)
│ │ ├── onboarding.tsx # Multi-step profile setup (sheet pattern)
│ │ ├── (tabs)/
│ │ │ ├── index.tsx # Donor home — FlashList, memoized renderItem
│ │ │ ├── request.tsx # Recipient post-request — Uber DECIDE/VERIFY/ACT
│ │ │ ├── donors.tsx # Discovery — list + map toggle, filter chips
│ │ │ ├── my-requests.tsx # Recipient's own requests
│ │ │ ├── history.tsx # Donor donation timeline
│ │ │ └── profile.tsx # Profile + settings
│ │ ├── request/[id].tsx # Request detail — live donor heartbeat
│ │ ├── donor/[id].tsx # Donor detail — compatibility + contact pills
│ │ ├── map/live.tsx # Live tracking map
│ │ ├── chat.tsx # Donor ↔ recipient chat (FlashList v2 inverted)
│ │ └── profile/edit.tsx # Edit profile modal
│ ├── components/ # Theme-tokenised primitives
│ ├── contexts/ # Auth · Theme · Toast
│ ├── hooks/ # Requests · location · notifications · heartbeat · chat
│ ├── utils/ # api.ts · supabase.ts · geo.ts · helpers.ts
│ ├── constants/ # Colors · Typography · BloodData
│ └── lib/errorReporter.ts # Typed logger wrapper — NEVER Sentry
├── backend/ # Express on Vercel
│ ├── src/
│ │ ├── controllers/ # auth · profile · request · donation · chat · stats · admin
│ │ ├── middleware/ # auth · errorHandler · validator
│ │ ├── routes/ # Express routers per resource
│ │ ├── utils/supabaseAdmin.js # service_role client
│ │ └── server.js
│ └── vercel.json # Function + cron config
├── supabase_schema.sql # Single source of truth — tables, RLS, RPCs, grants
├── migrations/ # Delta files for non-destructive Studio runs
├── verify_schema.sql # Single-row PASS/FAIL diagnostic
├── PUSH_NOTIFICATIONS.md # Killed-state delivery playbook
├── REDESIGN_PLAN.md # Canonical design language + 100%-wired mandate
└── HARDENING_NOTES.md # 28-flaw fix log
A request for N units of blood needs N distinct donors to accept and complete. One donor per unit. No exceptions.
flowchart TD
A["Recipient posts: 5 units AB+"] --> B["Geo-fence escalation begins"]
B --> C{"5 donors accepted?"}
C -- No --> D["Push to next ring"]
D --> B
C -- Yes --> E["Request stays 'active' until ALL 5 complete"]
E --> F["Donor 1..5 mark 'arrived & donated'"]
F --> G["complete_blood_donation RPC<br/>increments total_donations<br/>+ sets last_donation_date<br/>+ checks fulfillment"]
G --> H{"All N donors completed?"}
H -- No --> E
H -- Yes --> I["Request → 'fulfilled'<br/>Recipient + donors notified"]
The complete_blood_donation RPC enforces this atomically. A 5th donor cannot "complete" what the 1st through 4th haven't already donated against. There is no path to mark a request fulfilled while units remain.
flowchart LR
Start(["Request posted"]) --> R1["Ring 1 km · ~30 s"]
R1 -- "no accept" --> R2["Ring 5 km · ~60 s"]
R2 -- "no accept" --> R3["Ring 15 km · ~90 s"]
R3 -- "no accept" --> R4["Ring 30 km · ~2 min"]
R4 -- "no accept" --> R5["Ring 50 km · ~3 min"]
R5 -- "no accept" --> Country["Country-wide"]
R1 -- "accept" --> Heart["Live heartbeat starts"]
R2 -- "accept" --> Heart
R3 -- "accept" --> Heart
R4 -- "accept" --> Heart
R5 -- "accept" --> Heart
Country -- "accept" --> Heart
At each ring, the backend:
- Calls
nearby_compatible_donors(req_id, radius_km)— a Postgres function that filters by compatibility matrix, eligibility (age, cooldown, availability), andST_DWithinon the location. - Sends Expo Push to the cohort.
- Records ring + cohort size in
request_escalationsfor the analytics + cron timing decisions. - Holds the ring for the configured TTL or until N donors accept.
The geo-fence claim is DB-persisted with compare-and-set (CAS) semantics — two cron invocations or two cohorts cannot double-page the same ring.
| Recipient | Can receive from |
|---|---|
| O− | O− |
| O+ | O−, O+ |
| A− | O−, A− |
| A+ | O−, O+, A−, A+ |
| B− | O−, B− |
| B+ | O−, O+, B−, B+ |
| AB− | O−, A−, B−, AB− |
| AB+ | All groups (universal recipient) |
DONOR_FOR_RECIPIENT in constants/BloodData.ts is the canonical mapping. The backend filters by this matrix server-side; the client filters the home feed defensively.
The (tabs)/request.tsx screen is the locked design reference. Every other screen mirrors its pattern. The full spec lives in REDESIGN_PLAN.md §14, but the headline rules are:
- Sheet language — bottom sheet ascends with
-Spacing[5]overlap; brand accent strip (3 px crimson) + handle on top; spring entrance fromtranslateY 60. - Section labels — uppercase,
FontWeight.black,LetterSpacing.widest,theme.textMuted, indentedSpacing[2]. Questions, not nouns. - Inline CTA at form end — summary row (colored dot + bold tier label + bullet-separated facts) + pill button + footer micro-copy (privacy/reassurance).
- Breathing animation on critical/destructive CTAs (1.00 ↔ 1.02, 1500 ms infinite). Loss aversion via motion.
- Haptics on every Pressable —
Haptics.selectionAsync()for toggles,Haptics.impactAsync(Medium)for commits. - Theme tokens only —
theme.primary,theme.success,theme.warning,theme.danger,theme.surface,theme.cardElevated,theme.background,theme.border,theme.borderStrong,theme.textPrimary,theme.textMuted,theme.textTertiary,theme.textOnPrimary. Never hardcoded hex. - Pill-shaped 2026 visual —
Radius.pillfor actions and chips,Radius.xlfor cards,Radius['2xl']for sheet tops. - Skeleton loading only —
Skeletonshimmer matching content geometry. NeverActivityIndicator.
| Metric | Target | How we hit it |
|---|---|---|
| Frame rate on mid-tier Android | 120 FPS | Reanimated v4 worklets on UI thread; every animation uses useSharedValue + useAnimatedStyle. |
| Cold start | < 2 s to first paint | Splash screen until AuthContext resolves; app/index.tsx redirect gate prevents wrong-default-screen flash. |
| List scroll | 0 dropped frames | FlashList v2 with getItemType, memoized renderItem, stable keyExtractor. |
| Map | 60 FPS pan/zoom | react-native-maps with provider={PROVIDER_DEFAULT}, marker count capped per ring. |
| Realtime reconnect | < 5 s | Supabase client default retry; channels named uniquely with uniqueChannelName(prefix) to avoid collision on rapid re-mount. |
| Image cache hit | > 90% | expo-image with cachePolicy="memory-disk". |
- Row-level security on every table. Service role is the only path to bypass — and it's only used inside SECURITY DEFINER RPCs and the backend
supabaseAdminclient. - Trigger guards on
profilesallow the service role to update server-managed fields (total_donations,last_donation_date,is_verified,push_token) while blocking client-side writes. The trigger checkscurrent_user,current_role, and the JWT'srequest.jwt.claim.roleOR'd together — robust across PostgREST configurations. - Allowed-fields filter in
profileController.js— clients can only PATCH a whitelist. Server-managed fields are stripped server-side regardless of what the client sends. - Atomic RPCs for accept and complete — capacity, cooldown, age, and idempotency are checked inside the transaction. Failures roll back cleanly.
- JWT bearer auth on every API endpoint. The backend reads
Authorization: Bearer <token>and verifies against Supabase Auth before any work. - No service-role key in the client. The mobile app only knows the anon key. Service-role lives in
backend/.envand Vercel env vars. - OTP rate-limiting via Supabase Auth defaults. No client-side bypass.
- No PII in logs.
errorReporterredacts email/phone/coordinates by default.
# Clone
git clone https://github.com/aashir-athar/BludStack.git
cd BludStack/backend
# Install
npm install
# Configure
cp .env.example .env
# Fill in: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, JWT_SECRET, EXPO_ACCESS_TOKEN
# Run the schema (single source of truth)
psql $SUPABASE_DB_URL < ../supabase_schema.sql
# (Optional) Verify the schema is fully applied
psql $SUPABASE_DB_URL < ../verify_schema.sql
# Dev
npm run dev
# Production (Vercel)
vercel --prodcd BludStack/mobile
# Install
npm install
# Configure
cp .env.example .env
# Fill in: EXPO_PUBLIC_API_URL, EXPO_PUBLIC_SUPABASE_URL, EXPO_PUBLIC_SUPABASE_ANON_KEY
# Dev (Expo Go for quick iteration; dev build for push notifications)
npx expo start
# Dev build (required for push, recommended for everything else)
npx eas build --profile development --platform android
npx eas build --profile development --platform ios
# Production build
npx eas build --profile production --platform allHeads-up: push notifications will not be delivered to Expo Go on Android since SDK 53. Build a development client to test pushes.
| Key | Where to find it |
|---|---|
SUPABASE_URL |
Supabase Project Settings → API |
SUPABASE_SERVICE_ROLE_KEY |
Supabase Project Settings → API (server-side only) |
SUPABASE_DB_URL |
Project Settings → Database → Connection string (used for psql) |
JWT_SECRET |
Same value as Supabase JWT secret |
EXPO_ACCESS_TOKEN |
Expo dashboard → Access Tokens (for push) |
INTERNAL_CRON_TOKEN |
Random secret — used by Vercel Cron to call /internal/* |
DEBUG_ERRORS |
1 to surface pg codes in error responses; 0 in prod |
| Key | Where to find it |
|---|---|
EXPO_PUBLIC_API_URL |
Your Vercel deployment URL (e.g. https://bludstack.vercel.app) |
EXPO_PUBLIC_SUPABASE_URL |
Supabase Project Settings → API |
EXPO_PUBLIC_SUPABASE_ANON_KEY |
Supabase Project Settings → API (anon, not service role) |
The backend ships to Vercel Serverless. vercel.json declares the function timeout, regional preferences, and the cron schedule. Push to main → Vercel builds and promotes automatically.
The mobile app ships via EAS Build + EAS Submit. eas.json defines profiles for development, preview, and production. Internal channel updates flow via EAS Update (OTA) for non-native changes.
Configured in vercel.json:
| Path | Schedule | Purpose |
|---|---|---|
/internal/cron/escalate |
* * * * * (every minute) |
Promotes active requests to the next geo-fence ring if their TTL has elapsed. |
/internal/cron/expire |
*/5 * * * * |
Flips overdue requests to expired; notifies recipients. |
/internal/cron/cleanup |
0 3 * * * (daily 03:00 UTC) |
Purges stale request_responses in pending status older than 7 days. |
Each cron handler authenticates with INTERNAL_CRON_TOKEN before doing work.
Killed-state delivery is the most important reliability axis. The full playbook is in PUSH_NOTIFICATIONS.md, including per-OEM tuning (Xiaomi, OnePlus, Realme, Samsung battery saver / auto-launch / lock-screen permissions).
Critical-channel highlights:
- Android — dedicated
criticalchannel with bypass-DnD, alarm sound, max importance. The app registers it at startup. - iOS —
critical-alertentitlement requested at first run. - Tap deep-linking —
useNotificationDeepLinksreadsNotifications.useLastNotificationResponse()in_layout.tsxand routes to the right screen even from a cold launch.
Base URL: https://<your-vercel-url> (or http://localhost:3000 in dev). Every endpoint requires Authorization: Bearer <supabase access token> unless noted.
| Method | Path | Body / Query | Returns |
|---|---|---|---|
POST |
/profiles/register |
RegisterPayload |
{ profile, downgraded? } |
PATCH |
/profiles/me |
Partial<ProfilePatch> |
UserProfile |
GET |
/profiles/nearby-donors |
?lat&lon&radiusKm&bloodGroup |
Donor[] |
POST |
/requests |
CreateRequestPayload |
BloodRequest |
GET |
/requests/nearby |
?lat&lon |
BloodRequest[] (excludes own) |
GET |
/requests/mine |
— | BloodRequest[] |
POST |
/requests/:id/cancel |
— | BloodRequest |
POST |
/donations/accept |
{ requestId } |
RequestResponse |
POST |
/donations/decline |
{ requestId } |
RequestResponse |
POST |
/donations/complete |
{ requestId, donorId } |
BloodRequest (status → fulfilled when all units complete) |
POST |
/donations/heartbeat |
{ requestId, lat, lon } |
204 |
GET |
/stats/me |
— | { donations, livesHelped, lastDonatedAt } |
GET |
/chat/:requestId/messages |
?before&limit |
ChatMessage[] |
POST |
/chat/:requestId/messages |
{ clientId, receiverId, body } |
ChatMessage |
Errors surface as ApiError with code, message, optional pgCode/pgHint/details (when DEBUG_ERRORS=1).
The canonical schema is supabase_schema.sql at the repo root. Top-level tables:
| Table | Purpose |
|---|---|
profiles |
One row per user; role, blood group, contact, eligibility metadata |
blood_requests |
Recipient-posted needs; status, urgency, units, lat/lon, hospital |
request_responses |
Donor responses to a request; status, optional donor_lat/lon/donor_location_updated_at |
request_escalations |
Geo-fence ring + cohort log per request |
chat_messages |
Donor ↔ recipient thread scoped to request_id; idempotent on client_id |
push_tokens |
Per-device Expo push tokens |
All tables have RLS policies. The full DDL is generated and version-controlled — drop the file into Supabase Studio's SQL Editor to reset a project.
- Group requests (multiple recipients on a single thread for ward-level needs)
- In-app video consult for critical cases (E2EE via the noble crypto stack)
- Donor badges + leaderboards (city-anonymised)
- Web companion (Next.js) for hospitals to post on behalf of patients
- Multilingual UI (Urdu, Hindi, Arabic, Spanish — full RTL where applicable)
- Donor-recipient chat with images + voice notes (
expo-audio, notexpo-av) - Apple Vision Pro spatial map view for blood banks (experimental)
Is my data safe? Yes. Every table has row-level security. Recipient phone numbers are never visible to anyone until a donor accepts. No PII in logs. No external error monitoring SDKs.
What if no donor accepts? The geo-fence keeps expanding to country-wide. The request stays active until you cancel it or all units are fulfilled. You can repost at any time.
Can I be both a donor and a recipient? Yes — pick "Both" at onboarding. The role gates which tabs you see; "Both" sees everything.
How is donor eligibility enforced?
Age (≥ 18) is server-enforced. The 90-day cooldown is enforced by the accept_blood_request RPC. Availability is a profile toggle.
Why no MMKV?
Project decision. AsyncStorage covers every use case at our scale, with broader compatibility and zero native-link friction.
Why no Sentry?
Project decision. errorReporter keeps logs on-device or routes them to your own server. No third-party data export.
Why no emojis? Brand decision. We use Ionicons + custom SVG for every symbol. Consistency across themes, locales, and assistive tech is more important than playful glyphs.
Why is Reanimated v4 the only animation library?
UI-thread 120 FPS target. RN's legacy Animated runs on JS thread and drops frames under load. Reanimated worklets compile to UI-thread code.
Can I run BludStack against a different backend?
The mobile app only talks to two surfaces: Supabase (RT + Auth + DB) and the Express backend. Swap EXPO_PUBLIC_API_URL and EXPO_PUBLIC_SUPABASE_URL and you're good.
Where do I file bugs / request features? GitHub Issues for bugs, Discussions for features and questions.
Pull requests are welcome. The bar is high — but the door is open.
- One branch per change. Branch names are kebab-case with a prefix:
feat/,fix/,chore/,docs/,refactor/. Date suffix optional. - No emojis in code, copy, or commit messages.
- No
Alert.alertfor errors — use theuseToastcontext. - No
ActivityIndicator— useSkeletonshaped like the content. - No hardcoded hex outside
Colors.ts. Everything flows throughtheme.*tokens. - No
react-native-mmkv. All persistence is AsyncStorage orexpo-secure-store. - No external error monitoring SDKs.
- Strict TypeScript — no
anyin new code. Use the SDK's generated types. - Memoize list cells.
React.memo, stablekeyExtractor,getItemTypefor FlashList. - Haptic on every interactive
Pressable. Selection for toggles, impact for commits. - Skeleton loading only. Never spinners.
npx expo install <pkg>— never editpackage.jsonversions by hand.
# Backend
cd backend && npm run dev # nodemon + tsx if added
# Mobile (Expo Go for fast iteration)
cd mobile && npx expo start
# Mobile (dev build — required for push notifications)
cd mobile && npx eas build --profile development --platform android- Fork → branch → push.
- Open PR against
main. Title format:feat(scope): short description(Conventional Commits). - The PR must:
- Pass
tsc --noEmit. - Pass
expo-doctor. - Have a screenshot or short video for any UI change.
- Have a
Test plansection describing what you exercised.
- Pass
- Reviewers will check against the Canonical Design Language.
If BludStack helps someone you love, consider sponsoring on GitHub or sharing the app with a hospital near you.
MIT License © 2026 Aashir Athar
You are free to use, modify, and distribute this software with attribution. The clinical workflow (atomic accept/complete, age + cooldown enforcement, RLS posture) is intentionally permissive so other lives can be saved with it.