Context
VisionLink makes physical business cards that open a digital card when they're tapped or scanned. The VisionLink Dashboard is the web app behind them. It serves a public card page for each person, and gives companies a dashboard to manage their team's cards, see how they perform and pay for them. It runs on Nuxt and Supabase and has been in production since 2024. The QR codes on the cards come from a separate service, VisionLink QR v2, which has its own case study.
My role
I'm the lead developer and designer, in a team of four: two developers, one QA and one tester. The other developer is Jacques, VisionLink's co-founder, and we split the product between us.
In the first build, Jacques built the card editor, the admin pages, the partner features and billing. I helped build the public digital cards and the sign-up flow. I built card statistics, both collecting views and showing them, and teams inside companies.
For the relaunch, Jacques added AI features, billing on Paystack and more card templates. I reworked performance and security: fewer requests on the public card, less JavaScript, and hardening on the server and in the database, including row level security (RLS).
Problem
This study is about that rework. The public card is the page that matters most: every tap or scan of a physical card lands there, 1,207 times a day on average. Yet the visitor's browser did a lot of the work. An anonymous card view made six background requests, including two database reads sent straight from the browser to Supabase and a chain of calls to record the view and look up the visitor's location. Finding the card from its URL scanned the cards table with a leading wildcard, twice per view.
Security had the same root. With Supabase the browser talks to the database, so the rules in the database are the security model. RLS was off on a large part of the schema, which left table grants as the only rule.
Constraints
- A live product. Companies use the dashboard daily, and printed cards point at the public pages. Nothing could break a card link or lock a customer out.
- RLS had gone wrong before. Switching it on once already locked customers out of their own data, so it had to go in a table at a time, each change with a tested rollback.
- Two versions at once. The relaunch was built on its own branch while the old version ran production. Changes that depended on relaunch code had to wait for the switchover.
- A small team. Jacques was building AI features and billing at the same time, so my changes had to land beside his without blocking them.
- No testing on production. Every measurement came from a local test build.
Approach and architecture
The public card
I moved the card's database reads into the server render. The page now arrives with the card, the company's buttons and the team already in it, and the visitor's browser makes no database calls at all. I replaced the tracking chain with one beacon, which looks the card up by primary key and writes the view. Visitor location now comes from Vercel's geo headers instead of a third-party lookup. The two wildcard scans became primary-key lookups, and code the card only needs after a tap now loads on demand.
The database still does work on every view. The reads happen on the server, nothing is cached, and each view still writes a stats row.
Security
On the server, I made the routes that use the service role check for a verified session, and deleted unauthenticated routes that nothing called. In the database, I started from an inventory of every query the app makes against the tables with RLS off, on every live branch: who runs it, from which page, and what breaks if a policy is wrong. From that I wrote the migrations in waves and tested each one against the real app in a local lab before applying it. The first waves are live in production. The rest are written and wait for the relaunch switchover.
Key decisions and trade-offs
We built on Supabase from the start
- Chose
- Supabase for auth, Postgres and storage, with Nuxt server routes for anything that needs a secret
- Over
- A separate backend we would host and maintain ourselves
- Cost
- The database rules became the security model, and we had to retrofit RLS later
We were two developers building a product, not a platform. Supabase gave us auth, a real Postgres database and storage from day one, and it scales with the product. The catch came later: because the browser talks to the database directly, RLS has to be right from the start, and we left it off on too many tables.
Rebuild the sign-up flow rather than patch it
- Chose
- A new multi-step sign-up with its own store for plan, accounts and payment state
- Over
- Fixing the existing flow in place
- Cost
- Time on a rebuild instead of new features
The old flow was inefficient. We judged that reworking it would take less time than rescuing it, and the rebuild in early 2025 gave sign-up a single place to track the plan, accounts and totals as a customer moves through it.
Move billing from PayFast to Paystack
- Chose
- Paystack
- Over
- Staying on PayFast
- Cost
- A second payment integration to build and a move for existing subscribers
This was a product decision, made for lower fees and pay-as-you-go pricing. Jacques built the billing on Paystack. My part was on the security side, such as checking a sign-up's amount on the server before activating the account.
Move the public card's reads to the server
- Chose
- Server-rendered reads and one tracking beacon
- Over
- Tuning the browser's queries and the tracking chain as they were
- Cost
- Every view now does its database work on our server, and some behaviour changed
This was my call. Tuning the browser queries would have kept the database open to the browser for card data, which is the access RLS needed to close. Reading on the server fixed the speed and the security problem together. The cost is real: the server does more per view, and three behaviours changed. Views of companies with lapsed billing no longer count, cards with team slides turned off no longer show their team, and location comes from Vercel's geo headers.
Outcome
The product has 238 active digital business cards, one per user, counted in the production database on 28 Sep 2026. Cards are viewed 1,207 times a day, averaged over July to September 2026 from the view statistics the product stores.
The performance rework has merged into the relaunch branch and goes live with the relaunch. In network captures of an anonymous card load in a local test build, the visitor's browser now makes no database calls, background requests per view went from 6 to 1, the two wildcard scans became primary-key lookups, and the JavaScript the page ships went from 579 KB to 382 KB.
On security, the server routes now check who is calling, and the first RLS waves are live in production.
Reflection
I'd reverse how I built view tracking. When I built card statistics in April 2025, I recorded views from route middleware in the visitor's browser. That put a chain of calls and a third-party location lookup on every card view. A single server-side call from the start would have been simpler and faster. Eighteen months later, I took it apart as the core of the relaunch rework.
What I took into later work is a habit: start a public page by asking what the browser has to do, and keep everything else on the server.
The page people see most should ask the least of their browser.

