Building Cardly: React Native, Expo and Supabase
Cardly replaces the paper business card with one you never reprint. Here is how we built the mobile app, the scanner, the widgets and the web side.
Cardly is a digital business card app. You make your card once, hand it over as a link or a QR code, and the person on the other end opens it in whatever browser they already have — no app, no account. When you change jobs, every card you have ever shared updates with you. The other half of the product is the reverse direction: scan someone else's card, and Cardly reads it, tidies it and files it with a note about where you met.
We designed and built Cardly ourselves, which makes it a useful case study: every decision below was ours to make and ours to live with. This post walks through the product, the architecture, and the handful of choices that mattered most.
The product in one paragraph
The core loop has three steps: make your card, hand it over, keep the contact. Around that loop sit the features that make it a working tool rather than a toy:
- Sharing by link and QR — the receiver needs nothing installed. Because a card is a plain web URL, the same link also works anywhere a URL does, including an NFC tag.
- Scanning cards back — point the camera at a paper card, a QR code or an email signature, and the fields are extracted and saved.
- Contacts that stay findable — notes, tags and follow-up reminders on every person you meet.
- A home-screen widget that puts your QR code one tap away on iOS and Android.
- Companies and teams — one company pays, invites people by email, and its members get Pro with the company design on their cards.
- Offline support, light and dark themes, Apple Wallet and Google Wallet passes, and data export.
Architecture: Expo app, Supabase backend
The mobile app is React Native with Expo (SDK 54, React Native 0.81) and Expo Router for file-based navigation. State lives in Zustand, forms use React Hook Form with Zod validation, and i18next handles translations. We chose Expo because it lets a small team ship to both stores from one codebase while still dropping into native code where it matters — and on Cardly it mattered in three places: text recognition, wallet passes and widgets.
The backend is Supabase: Postgres with row-level security on every user-owned table, Supabase Auth (email, Apple and Google sign-in), Storage for images, and Edge Functions for anything that needs a secret — billing webhooks, company invites, wallet pass generation, account deletion and CRM sync. The schema is managed as plain SQL migrations, and we made sure the whole database can be rebuilt from those migrations alone. That sounds like housekeeping, but it is what makes a staging environment trustworthy.
Row-level security did a lot of the heavy lifting. A public card has to be readable by anyone with the link, while the owner's contacts, notes and imported address book must be readable by nobody else. Expressing that in the database, rather than in API code, means there is one place to get it right.
Native edges: scanning, widgets and wallets
Scanning: on-device first, AI as the upgrade
Card scanning is where most apps in this category disappoint, so we spent real time on it. The first pass runs on the phone: we wrote a small Expo native module that uses Apple Vision on iOS and Google ML Kit text recognition on Android. One photo can contain several cards; the app splits them and offers each one as a separate contact. A rules-based parser then maps the raw text to name, role, company, email, phone, website and address.
On-device OCR is fast and private, but real business cards are messy: vertical layouts, two languages, logos that read as text. For those, a Supabase Edge Function sends the text (or the photo) to a small Claude model and gets structured fields back. If the AI step is not configured or fails, the app keeps its own result. That fallback order — device first, model second, never block the user — is the pattern we recommend for most custom AI development inside mobile apps.
Widgets, wallets and offline
The widget exists for one moment: somebody asks for your details and you have a drink in your other hand. On iOS it is a native WidgetKit extension written in Swift, added to the Expo project as an Apple target, reading the card from a shared App Group. On Android it is built with react-native-android-widget. Both render the QR code locally so the widget works without a network.
Wallet passes were similar. Apple's review process flags binaries that link PassKit unnecessarily, so pass generation happens server-side in an Edge Function and the app only hands the finished pass to the system. Offline support is a cache of your cards and contacts in AsyncStorage, with a connectivity check that treats a captive-portal hotel Wi-Fi as offline rather than trying, failing and only then falling back.
One Astro repo, two websites
The web side is a single Astro project with Tailwind, deployed as two Railway services: the marketing site at www.usecardly.app and the signed-in web app at app.usecardly.app. The split exists for a concrete reason. A Supabase session lives in localStorage, which is per-origin; serving account pages from both hosts would mean two sessions and two places to sign in. So the product gets its own origin, and every emailed auth link and company invite points there.
Both sites build from the same pages. A small post-build script keeps the half that belongs to each target and writes redirects for the other half, and all links go through two helpers so they resolve correctly in development, on www and on app. Shared header, footer, design tokens and Supabase client stay in one place instead of drifting as copies.
Payments and team billing
Cardly is free for one card, with a Pro subscription for more. Payments run on Stripe: the Stripe payment sheet in the app (card, Apple Pay, Google Pay), Stripe Checkout on the web, and Stripe's billing portal for managing or cancelling. Webhooks update subscription state in Postgres, and the subscription belongs to the account, so it follows the user to every device.
Company plans are bought on the web, where a finance team expects an invoice rather than a purchase on somebody's phone. Seats are counted from the moment an invite goes out, and invitees get Pro as soon as they accept. The store review process shaped this more than anything else: clear pricing, auto-renewal wording, in-app account deletion that removes every row the user owns, and a written explanation of how imported contacts are stored and protected.
What we would tell another team
- Make the receiving side need nothing. A link that opens in any browser beats any amount of in-app polish.
- Put authorisation in the database. Row-level security is easier to audit than scattered API checks.
- Run AI as an upgrade path, not a dependency. On-device first, model second, user never blocked.
- Budget for native edges. Expo covers most of the app, but widgets, wallet passes and OCR each needed native work.
- Read the store guidelines before designing payments, not after the first rejection.
Building something similar?
Cardly is the kind of product we enjoy building: a focused mobile app with a real backend, native edges and a web side that has to agree with it. If you are planning one, see our mobile app development service, read how we build React Native apps, or get in touch.
