Overview
Roluna reframes the job hunt as a swipe deck. Users sign in, set a target role with optional filters (location, distance, job type, salary range), and swipe through live job listings rendered as cards: right to save, left to pass. Saved listings live on their own tab where they can be marked as applied, and tapping a saved row opens the original posting on the source site.
Mental model: search results, rendered as a Swipe deck, with persistent Saved and Applied state.
It's a full-stack solo build: a Node/Express backend serving a cache of real job listings, and a React Native iOS client with a hand-built, gesture-driven swipe deck.
The app
Production iOS app icon: a warm clay r on the brand gradient.

Welcome 
Discover 
Filters 
Saved 
Settings
High-fidelity UI · Clay theme · designed in-prototype, captured at iOS 402×874
Discover
A search bar opens a full-screen modal with role autocomplete (~180 roles, fuzzy-matched client-side). A filter rail shows every active filter. The centrepiece is a custom swipe deck built on Reanimated gestures, with animated SAVE/PASS stamps that fade in as you drag and a multi-level undo that survives across the session.
Filters
A half-sheet with location autocomplete (~190 US cities), a stepped distance slider, multi-select job-type chips, a min/max salary range with validation, and date-posted controls.
Saved
An animated segmented control switches between All / To apply / Applied. Each row can be marked as applied or opened on the source site, with a remove confirmation.
Settings
Editable defaults for Role, Location, and Job types persisted on-device, plus account management and Apple-compliant account deletion.
Smart defaults
User defaults are stored locally; on a cold start the app auto-seeds Discover from them, and the first manual search of a session takes over.
Cache-first deck
The backend uses a cache-first deck strategy. A scheduled job ingests listings from the Adzuna API into a shared MongoDB collection twice a day; user searches then hit that cache the vast majority of the time, with an on-demand API fallback for long-tail queries. This decouples user activity from the API's daily quota and keeps the whole system comfortably inside a free tier.
Adzuna APIsource
US listings, on a daily quota.
Ingest cron2× a day
A twice-daily sweep into the cache, under a Redis lock.
Mongo cache14-day TTL
Shared jobs, cleaned up on a rolling 14-day window.
Express APIbackend
Deck, swipes and saved roles, with the on-demand fallback for long-tail queries.
Swipe deckclient
The iOS client, built on Reanimated.
Saved snapshotper user
A copy of the job taken at save time, which survives the cache purge.
Key design decisions
Cache-first, quota-aware
Twice-daily bulk ingestion populates a shared listings cache, so most user searches never touch the live API; an on-demand fallback handles rare queries. This single decision is what makes a free-tier listings quota viable for an MVP.
Per-user state that survives the purge
Saved listings denormalise a snapshot of the job at save time, so a saved role stays intact even after the rolling 14-day cache cleanup removes the original posting. Dismissed jobs auto-expire via a database TTL index.
Shared validation contract
One set of Zod schemas (job types, search inputs with a salaryMax ≥ salaryMin refinement, swipe payloads) is shared across client and server through a monorepo workspace, so the two sides can't drift out of sync.
Auth bridge
Firebase handles identity; the backend mints its own user record keyed by the Firebase UID and verifies the token on every request, bridging Firebase Auth to server-side data ownership.
Architecture
| Layer | Choice |
|---|---|
| Mobile | React Native + Expo (managed), TypeScript strict |
| Navigation | Expo Router (file-based) |
| Server state | React Query (TanStack) |
| Auth | Firebase Auth: email/password + native Apple & Google Sign In |
| Gestures / animation | react-native-gesture-handler + react-native-reanimated |
| Backend | Node + Express + TypeScript |
| Database | MongoDB Atlas |
| Cache / queues | Upstash Redis |
| Listings | Adzuna API (US) |
| Validation | Zod schemas shared between client and server |
| Hosting / CI | Render (push-to-deploy) + GitHub Actions |
| Distribution | Expo EAS Build → TestFlight |
Engineering challenges
| Hurdle | Resolution |
|---|---|
| Dev build launched to a blank white screen | An SDK-version mismatch: a dependency installed for a newer Expo SDK called native APIs that didn't exist in the pinned React Native version. Pinned every native dep to its SDK-matched version and reinstalled clean. |
Unable to find a specification for 'EXDevLauncher' | In an npm-workspaces monorepo, Expo's iOS autolinker doesn't recurse into nested node_modules. Hoisted the transitive native modules by declaring them explicitly in the leaf workspace. |
| New searches kept showing stale cards | The deck only reloaded when empty, so a fresh search left the old cards in place. Tracked the previous query hash in a ref and split the logic into a query-change effect and a top-up effect. |
ITMS-90725 rejection at submit | Apple's iOS 26 SDK gate flipped to enforced mid-submission. Switched the cloud build worker to the latest macOS/Xcode image, a one-line fix that satisfied the gate even on the older Expo SDK. |
Stack
- React Native
- Expo
- Expo Router
- TypeScript
- React Query
- Firebase Auth
- Reanimated
- Node
- Express
- MongoDB
- Redis
- Zod
- Adzuna API
- Render
- GitHub Actions
- EAS Build
- TestFlight
Status
The backend and mobile app are complete and run end-to-end on a production TestFlight build. Remaining work is the App Store submission itself: privacy policy, listing metadata, screenshots, and review. The project was built and is run entirely on free tiers, with the Apple Developer Program as the only fixed cost.