Skip to content
Prabin Singh Thakuri
Live on Google Play

Mero Loksewa

Exam-prep app live on Google Play with 1,000+ downloads and a 4.3 rating.

React NativeTypeScriptNode.jsMongoDB
Mero Loksewa home screen with the daily quiz banner and popular exam categories

Mero Loksewa home screen with the daily quiz banner and popular exam categories

1 / 5

Role

Solo full-stack developer — product, mobile, backend, DevOps, content pipeline

Platform

Android on Google Play (the Expo codebase can also target iOS)

Timeline

March 2026 to present · v1.4.0 · ~190 commits

Source code: private repository.

Exam tracksNasu, Kharidar, Adhikrit, Pharmacy, Teacher (MAVI/NIMAVI), NRB Banking, Civil Engineering, Government Organisations, plus general subjects: GK, Constitution (Sambidhan), Nepal Parichaya, Periodic Plans (Yojana), Monetary Policy, Computer, IQ, Current Affairs
App surface~120 screens, 20+ reusable learning components
Backend49 REST route modules, 56 Mongoose models, 4 scheduled jobs
LanguagesContent authored once, served in both Nepali and English
OfflineStudy content works with no network after the first load

TL;DR

  • Solo-built and shipped: a React Native/Expo app, an Express 5 + MongoDB API, and a Cloudflare R2 content pipeline covering 10+ Loksewa exam tracks — live on Google Play with 1,000+ downloads and a 4.3 rating.
  • Offline-first by data design, not a UI trick: versioned JSON modules with atomic temp-file writes and a content-hash-diff publish script, so study material keeps working with no network after the first load.
  • Content is written once and served in Nepali and English through a reusable Mongoose plugin that translates on save — and when Express 5 quietly broke the usual security middleware, I wrote a replacement instead of downgrading.

The problem

Every year, hundreds of thousands of candidates in Nepal sit the Loksewa Aayog (Public Service Commission) exams. Preparation today is fragmented and poorly suited to how candidates actually live:

  • Material is scattered across printed guidebooks, PDFs on Facebook groups, YouTube, and coaching centres. Each exam track (Nasu, Kharidar, Adhikrit, and so on) has its own syllabus, and no single place maps content to it.
  • Connectivity is unreliable. Many candidates live outside the Kathmandu valley on patchy mobile data. Apps that need a live connection to show a question feel broken.
  • Language is a barrier both ways. Exams are mostly in Nepali, but many learners (and some papers, such as banking and computer) work better in English. Existing resources pick one language.
  • Self-study is lonely and hard to sustain. Preparation runs for months. Without feedback, streaks, or a community, candidates drop off.
  • Devices are low-end. Budget Android phones with limited storage are the norm, so a bloated app gets uninstalled.

Goal: One app that covers every major exam track, works offline on cheap phones, speaks both languages, and keeps learners coming back every day.

Target users

  • Primary: Loksewa candidates aged 18–35 preparing for a specific track (for example a Nasu or Kharidar aspirant in a district town).
  • Secondary: Candidates preparing for related exams (NRB banking, teacher licensing, pharmacy, engineering).
  • Constraints: Budget Android devices, intermittent 3G/4G, Nepali-first reading, study in short sessions.

The solution

A React Native (Expo) app backed by a Node/Express + MongoDB API. The app has three main parts:

Structured learning by exam track

Each track mirrors its official syllabus as papers → topics → study notes, MCQ practice, mock tests, and past papers. Onboarding asks which exams the user is preparing for, and the home screen shows those tracks first.

Offline-first content delivery

Study modules are published as versioned JSON to Cloudflare R2, downloaded to device storage, and re-downloaded only when a version manifest says they changed. Seed data bundled with the app means even a first launch with no network shows something useful.

Engagement

XP, daily streaks, daily and all-time leaderboards, subject-mastery badges, a Q&A community forum, referrals, shareable badge cards, a daily quiz, and scheduled push reminders.

Key features

Universal quiz engine. One quiz component powers every MCQ set, mock test, and past paper across all tracks, with explanations and result submission.

Study mode and structured notes. Rich notes with Markdown, tables, and math. Includes a syllabus viewer and a PDF course viewer.

Bilingual toggle. Switch between Nepali and English anywhere in the app. The preference syncs to the user's profile.

Personalised homepage. Multi-select exam-track onboarding decides which sections appear. Guest mode lets people browse before signing up.

Current affairs and e-papers. Daily Gorkhapatra, Rising Nepal, and Loksewa e-papers are scraped automatically on a schedule.

Community forum. Ask and answer questions, accept answers, and earn XP and badges for helping others.

Gamification. XP, streaks, leaderboards, 14 subject-mastery badges, referral badges, and badge cards you can share to social media.

Bookmarks, daily quiz, push notifications. Daily reminders on Kathmandu time, plus per-user alerts such as "your question got an answer".

Monetisation. Google AdMob with mediation, configured through a custom Expo config plugin.

Tech stack

Mobile & navigation

  • React Native 0.81 (New Architecture)
  • React 19
  • Expo SDK 54 (dev client, EAS Build)
  • Hermes
  • React Navigation v7

State, data & on-device storage

  • React Context
  • useSmartFetch
  • useModuleData
  • expo-file-system
  • AsyncStorage
  • expo-secure-store

UI & media

  • Reanimated 4
  • react-native-pdf
  • blob-util
  • react-native-markdown-display
  • react-native-view-shot
  • react-native-share
  • WebView

Backend & auth

  • Node.js
  • Express 5
  • Mongoose 9
  • MongoDB Atlas
  • JWT (Bearer)
  • bcryptjs
  • Nodemailer (email OTP)

Storage, CDN & messaging

  • Cloudflare R2 (AWS SDK v3)
  • Firebase Cloud Messaging
  • Firebase Admin
  • Notifee

Analytics, jobs & translation

  • Firebase Analytics
  • Firebase Remote Config
  • node-cron (Asia/Kathmandu)
  • axios + cheerio
  • Google Translate

Security

  • helmet
  • express-rate-limit
  • custom NoSQL-injection/XSS sanitiser
  • CORS allow-list

Hosting

  • Render (API)
  • Cloudflare R2 (content)
  • Google Play (app)

System architecture

Android app

React Native / Expo

  • ~120 screens — quiz engine, study mode, notes, PDF viewer
  • useModuleData → local JSON (expo-file-system) ← version manifest
  • useSmartFetch → AsyncStorage cache → bundled seed data (last resort)
  • SecureStore (JWT) · LanguageContext (np/en) · UserPreferencesContext
  • FCM + Notifee · Firebase Analytics / Remote Config · AdMob

Express 5 API

Render

  • helmet · CORS allow-list · 10 KB body limit
  • NoSQL/XSS sanitiser · rate limiters
  • 49 route modules → controllers/handlers
  • JWT protect / optionalAuth / admin-key gate
  • localize(doc, lang) response projection
  • Cron: e-paper scrapers, notifications, daily quiz

Cloudflare R2

content CDN

  • modules/manifest.json
  • modules/<key>_<lang>
  • PDFs, images

publishModulesToR2.js reads each registered module from MongoDB, hashes it, and uploads only the modules that changed to R2 (content-hash diff), then rewrites manifest.json with the new versions.

MongoDB Atlas

56 models

  • Content (bilingual plugin)
  • Users, progress
  • Forum, scores

Firebase Cloud Messaging

  • Broadcast + per-user push

Request flow for a study screen: the screen asks useModuleData for a module. The hook reads the local file straight away if one exists. It then checks the small R2 manifest and downloads only if that module's version changed. If R2 is unreachable and nothing is cached locally, it falls back to the Express endpoint. The screen shows content at every step and never blocks on the network.

System design highlights

Offline-first content pipeline

Gen 1 (useSmartFetch) cached full content blobs in AsyncStorage — simple, but it made app storage balloon on budget phones. Gen 2 (useModuleData) moved large study modules out of the API response path entirely: versioned JSON files in app-scoped file storage, downloaded only when a manifest says the content changed.

Technical deep dive
  • A publish script exports each registered module from MongoDB, serialises it deterministically (sorted keys), hashes it, and uploads only the modules that changed to R2, then rewrites a small manifest.json with the new versions. It can be scoped with --model or --topicId so publishing stays fast across hundreds of topics.
  • Atomic writes: each download goes to a .tmp file and is renamed only on success, so if the app is killed mid-download it never leaves a half-written file that later looks like a valid cache entry.
  • Cache-busting: a ?v=<version> query string forces Cloudflare's edge to serve exactly the bytes the manifest promised.
  • Retention policy: per-topic modules are evicted after 24 hours of inactivity, with a cap of 10 per subject group. A sweep at boot catches modules the user never reopens.
  • Graceful migration: if R2 fails, the hook falls back to the legacy API route and writes that response into the store with a null version, so the next successful manifest check triggers a proper versioned download. Screens moved from Gen 1 to Gen 2 with minimal code changes because the hook returns the same data shape.

Write-once bilingual content

Authoring every question and note twice wasn't realistic for one person. A reusable Mongoose bilingualPlugin detects the language content was written in, translates only the fields that need it, and stores the result in a translatedContent mirror — so clients just add ?lang=en to the URL.

Technical deep dive
  • On save, the plugin pulls out only the string leaves that need translating and stores the translated result in a translatedContent mirror.
  • Identical strings are translated only once. Requests are throttled (2 at a time, with a 1-second gap between batches) to stay under the translation endpoint's abuse threshold.
  • enforceAnswerConsistency makes sure the translated correct answer still matches one of the translated options, so the quiz never marks a correct answer as wrong.
  • On read, localizeDoc(doc, lang) returns the requested language — the client just adds ?lang=en to the URL.

Custom Express 5 sanitiser

The common packages (express-mongo-sanitize, xss-clean) crash under Express 5 because req.query became a read-only getter. Rather than downgrade or leave the API unprotected, I wrote a replacement.

Technical deep dive
  • Removes MongoDB operator keys ($gt, $ne, ...) and dotted keys from request bodies and URL params in place.
  • Plugs into Express as a custom query parser, so req.query is clean without ever being reassigned.
  • Strips HTML tags from all string values.
  • Escapes user input used inside regex queries first, which prevents ReDoS and regex injection.

Also in the codebase

  • Mastery badges need 5 separate passing sessions (score ≥ 60%), and each module counts at most once per day, so a badge can't be farmed by retaking one quiz five times in a row.
  • Notification cron works out the day and hour in Kathmandu time, independent of the server's timezone, avoiding off-by-one-day bugs around midnight; reminders send as FCM multicast with Android high priority.
  • The API runs on Render's free tier, which sleeps when idle. The app pings a lightweight /api/system/popup endpoint at boot to wake it while the user sees cached content.

Security

The custom NoSQL-injection/XSS sanitiser is covered in the system design highlights above. The rest of the hardening:

API hardening

  • helmet sets secure HTTP headers
  • CORS uses an explicit origin allow-list
  • JSON request bodies capped at 10 KB
  • Global limit: 300 requests / 15 min
  • Strict auth limit: 10 requests / 15 min on login, register and every OTP endpoint

Authentication & sessions

  • Passwords hashed with bcrypt (salted)
  • Stateless JWT: protect rejects invalid tokens, optionalAuth accepts guests on public-with-personalisation routes
  • Server refuses to start if JWT_SECRET is unset — never falls back to a weak default
  • JWT lives in the OS Keychain/Keystore (expo-secure-store), not plain AsyncStorage
  • Any 401 clears the whole session so the UI can't show stale logged-in state
  • Time-limited email OTP for password reset, cleared once used
  • Content-management routes sit behind a separate admin-key header, not tied to any app user account

App hardening

  • ProGuard/R8 minification and resource shrinking in release builds
  • console.* calls stripped from production bundles (babel plugin)
  • Premium PDFs carry an on-screen watermark to discourage screenshot redistribution

Challenges and trade-offs

ChallengeDecisionTrade-off
Unreliable networksCache-first and file-based modules, with seed data as a last resortContent can be up to one sync behind
AsyncStorage bloatMoved large payloads to file storage plus the R2 manifestNeeds a separate publish step after editing content
Two languages, one authorAutomatic translation on saveMachine translation needs spot-checking, and answers are re-validated automatically
Express 5 broke security middlewareWrote a custom sanitiser and query parserMore code to own and maintain
Free-tier sleeping serverWake-up ping plus serving content from the R2 edge cacheFirst API call after idle can still be slow
Badge farmingPasses deduplicated per module per daySlower badge progress for honest users
~120 screens in one stackShared quiz/study/notes components reused across every trackAdding a track still means registering screens by hand

Results

1,000+

Downloads on Google Play

4.3

Average Play Store rating

Fill inActive users / DAUFill inReview count behind the 4.3 ratingFill inRetention or streak stats (Firebase Analytics)Fill inContent volume — questions, notes, mock setsFill inA notable user feedback quote

What I learned

  • Offline-first is a data-design problem, not a UI trick. Versioning, atomic writes, and eviction mattered more than any spinner.
  • Build for the device your users actually own. Storage and memory limits on budget Android phones drove the biggest architectural change (Gen 1 → Gen 2).
  • Automate the boring half. The bilingual plugin and the hash-diffed publish script are why one person can run 10+ exam tracks in two languages.
  • Framework upgrades can quietly break security. The Express 5 req.query change turned popular sanitisers into crashes, and I only understood why by reading the source.

What's next

  • Automated tests: API integration tests and component tests for the quiz engine.
  • Stronger auth: short-lived access tokens with refresh-token rotation and revocation, OTPs generated with crypto.randomInt and stored hashed, and constant-time admin-key comparison.
  • A web admin dashboard to replace Postman-based content management.
  • Moving the API off the free tier (or adding a keep-alive) to remove cold starts.
  • A config-driven navigator to cut down on registering screens by hand.
  • iOS release.