Pray Tracker: Engineering Mindfulness
Pray Tracker is a full-stack prayer companion that lives in a single Turborepo, spanning an Expo mobile app, a NestJS API, and a Next.js marketing site. It's proof that spiritual technology can be both meaningful and technically excellent.
The Challenge
Prayer tracking is deeply personal yet often disconnected across devices. Existing solutions lack:
- Cross-platform sync – Mobile and web experiences feel like separate apps
- Community features – No way to stay accountable with friends
- Developer velocity – Monolithic codebases slow down iteration
- Multilingual support – Limited accessibility for global users
Pray Tracker solves these challenges with a modern monorepo architecture that treats the product as one cohesive experience.
The Solution
📱 Mobile-First Experience (Expo + React Native)
- Offline-friendly prayer logging with local persistence
- Beautiful, intuitive UI with smooth animations
- Push notifications for prayer reminders
- TanStack Query for optimistic updates
- Full i18n support with localized error messages
🚀 Robust Backend (NestJS + Prisma)
- RESTful API with automatic validation
- Global exception filter for localized errors
- Efficient prayer aggregation and statistics
- Friend system with privacy controls
- Leaderboards (global and friend-only)
🌐 Marketing Site (Next.js 14)
- Server-side rendering for SEO
- Shared UI components with mobile app
- Authentication flows with Better Auth
- Responsive design system
🔧 Shared Infrastructure
- TypeScript configs across all apps
- ESLint rules for consistent code quality
- Shared UI primitives and design tokens
- Centralized Prisma schema
Technical Architecture
Monorepo Advantages
Everything lives in one Turborepo, giving us:
- End-to-end type safety – Changes to the API automatically update mobile types
- Shared code – UI components, utilities, and configs are reused everywhere
- Atomic deployments – Backend and frontend changes ship together
- Faster CI/CD – Turborepo's caching makes builds lightning-fast
Backend Design
The NestJS application bootstraps with strict validation and localized error handling:
// apps/backend/src/main.ts
app.useGlobalFilters(new HttpExceptionFilter())
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
})
)
app.enableCors({ origin: ALLOWED_ORIGINS, credentials: true })
The service layer uses Prisma to efficiently upsert daily prayer logs and calculate statistics:
// apps/backend/src/modules/prayers/prayers.service.ts
return this.prisma.prayer.upsert({
where: { userId_date: { userId, date: new Date(date) } },
update: prayerData,
create: { userId, date: new Date(date), ...prayerData },
})
Mobile Architecture
Expo bootstraps through a stack router that loads fonts, guards sessions, and defers rendering until assets finish. Global providers wrap the app with essential services:
// apps/mobile/providers/root.tsx
<QueryProvider>
<I18nProvider>
<ThemeProvider>
<PortalProvider>{children}</PortalProvider>
</ThemeProvider>
</I18nProvider>
</QueryProvider>
The query provider handles network errors gracefully with localized toast messages:
// apps/mobile/providers/query.tsx
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: { retry: 0, refetchOnWindowFocus: false },
mutations: { retry: 0 },
},
queryCache: new QueryCache({
onError: (error, query) => errorHandler(error),
}),
mutationCache: new MutationCache({
onError: (error) => errorHandler(error),
onSuccess: (data) =>
data.success && fireToast.success(data.message ?? 'Success'),
}),
})
)
Internationalization
The i18n provider syncs Expo translations with Zod's error maps, ensuring validation feedback respects the current locale:
// apps/mobile/providers/i18n-provider.tsx
useEffect(() => {
const locale = getLocales()[0].languageCode
i18n.locale = locale
z.setErrorMap(zodI18nMap)
}, [])
The monorepo pattern means once a provider is tuned in one app, every surface benefits. This is the main architectural win.
Code Highlights
Prayer Leaderboards
The leaderboard service aggregates global and friend-only rankings with efficient Prisma queries:
// apps/backend/src/modules/leaderboard/leaderboard.service.ts
const leaderboard = await this.prisma.user.findMany({
select: {
id: true,
name: true,
avatar: true,
_count: {
select: { prayers: true },
},
},
orderBy: {
prayers: { _count: 'desc' },
},
take: limit,
skip: offset,
})
Offline-First Mobile
Prayer logs are optimistically updated on mobile, then synced when connectivity returns:
const { mutate: logPrayer } = useMutation({
mutationFn: (prayer: PrayerLog) => api.prayers.create(prayer),
onMutate: async (newPrayer) => {
await queryClient.cancelQueries({ queryKey: ['prayers'] })
const previous = queryClient.getQueryData(['prayers'])
queryClient.setQueryData(['prayers'], (old) => [...old, newPrayer])
return { previous }
},
onError: (err, newPrayer, context) => {
queryClient.setQueryData(['prayers'], context.previous)
},
})
Demo
Impact & Learnings
What This Project Demonstrates:
✅ Monorepos accelerate development – Shared code and types eliminate duplication
✅ Type safety prevents bugs – API changes immediately surface in mobile app
✅ Localization is essential – Global apps need multilingual error handling
✅ Offline-first matters – TanStack Query makes optimistic updates trivial
Key Takeaways:
- Turborepo is a game-changer for full-stack TypeScript projects
- NestJS + Prisma provides enterprise-grade backend architecture
- Expo + React Native delivers native-quality mobile experiences
- Shared providers eliminate boilerplate across platforms
- Global exception filters make error handling consistent and localized
This architecture pattern works for any cross-platform app: fitness tracking, habit formation, journaling, or meditation. The monorepo approach ensures consistency while maintaining development velocity.
Explore the source code on GitHub to see how it all fits together!