Context
VisionLink puts QR codes on physical products, including metal business cards and codes printed on vehicles. Every scan goes through VisionLink's QR service, which redirects the phone to the code's destination. QR v2 replaces the original QR project, a Nuxt app on Vercel. Other products depend on it too: the VisionLink Dashboard, which runs the digital business cards, now creates its codes through the new API.
My role
I was the lead developer and the only engineer on the build. Jacques, VisionLink's co-founder, and I started planning in July 2026, and the first commit was on 19 August. I designed the architecture, built the Go service, the Next.js dashboard, the database migrations and their tests, and ran the cutover. Jacques handled the business side: the payment provider account, DNS for the domain move, and the provider accounts I didn't hold.
Problem
The legacy service worked, but it was slow and hard to trust. Redirects took about 4 s at p95, and creating a code took about 2 s. The legacy dashboard dipped to 12 fps. The legacy tables had row-level security off and full write grants for the public key, which ships in the site's JavaScript, so anyone who found it could retarget or delete a printed code. Any rebuild also had to keep every code already printed on a metal card working, because those can't be recalled.
Constraints
- Printed codes are permanent. The old
qr.visionlink.co.za/qr/URLs are printed on metal cards and vehicles, so they have to redirect forever. - One shared database. v2 had to use the same Supabase Postgres as the legacy app, which stayed live until the swap.
- No fallback for the old app. It ran on Vercel with nothing behind it, so the domain swap happened in a maintenance window outside business hours, with customers told in advance.
- Codes are case-sensitive.
00003Kand00003kare different codes, so nothing in the chain may lowercase a path.
Approach and architecture
I split the system in three: a Go service for scans and the API, a Next.js dashboard, and the shared database between them.
The scan path
A scan hits vlcard.me followed by the code, served by a Go service on Fiber, hosted on Railway. It looks the code up in the new codes table and then the legacy table, and answers with a 302 and Cache-Control: no-store, never a 301, so a destination can always change. Scan analytics are queued after the redirect and written in batches, so a slow write never holds up a scan. If a code is paused, expired or unknown, Go serves its own branded page, so a printed code still explains itself when the dashboard is down.
The dashboard and the API
The dashboard is Next.js on Vercel. It reads through row-level security, and every write goes through database functions. The old printed URL is a small route in the dashboard that redirects to the scan host. Businesses use a /v1 API with API keys and per-plan rate limits.
Getting to the swap
Before the swap I checked every legacy row against the new code and destination rules, and wrote a smoke test that follows each printed URL through both hops. Database tests run in CI with pgTAP. The domain swap was on 21 September, and the legacy tables were locked the next day.
Key decisions and trade-offs
Split the scan service from the dashboard
- Chose
- A Go service for scans and the API, and a separate Next.js dashboard, sharing one database
- Over
- One Next.js app for the dashboard, redirects and API, like the legacy app
- Cost
- Two deploys and two languages, with the database as the contract between them
Scans are what customers feel, and they must keep working when the dashboard is down. A long-running Go process keeps serverless cold starts off the scan path. Sharing the database meant the new service could read legacy codes from day one without a data migration.
Keep one legacy URL, as a thin redirect
- Chose
- A route in the new dashboard that hands every printed legacy code to the Go scan host
- Over
- Porting the legacy app's redirect, create and stats endpoints
- Cost
- An extra hop on every legacy scan, and no scan analytics for legacy codes yet
Only the printed URL had to live forever. Porting the rest would have kept legacy quirks, such as sending unknown codes to a login page, and split lookup across two services. Now Go is the only place that resolves a code or records a scan.
Lock the legacy tables only after the swap
- Chose
- Tighten the new tables first, and lock the legacy tables the day after the swap
- Over
- Turning on row-level security for every table before the cutover
- Cost
- The legacy tables stayed open to the public key until 22 September
The live legacy app still created codes as the anonymous role, and its ID default needed those grants. Locking early would have broken it. The migration that revokes the ID generator has a guard: it refuses to run while any anonymous insert still depends on it.
Never answer a scan with a 429
- Chose
- Keep redirecting above 3,000 scans a minute from one IP, and skip analytics instead
- Over
- Rate-limiting scans the way the API is limited
- Cost
- Analytics can undercount a burst, and a flood of scans is absorbed rather than blocked
Someone scanning a printed card can't retry or read an error message. The API has per-key limits, but a scan over the limit still gets its redirect. Only the analytics write is dropped.
Outcome
The cutover broke no printed code. 177 of 177 legacy codes still resolved after it, in smoke tests from three machines against production on 22 September 2026. In parallel stress tests from three machines on the same day, the new redirect path held 312 ms at p95, against about 4 s on the legacy service. Creating a code went from 2 s to 500 ms, timed by a script from sending the request to the successful response on both services. Printed legacy codes, which still pass through the old route first, also redirect in about half the time they used to. Most of what's left is cold starts on the Vercel functions behind that route. The dashboard held 60 fps under 4× CPU throttling in Chrome DevTools, where the old one dipped to 12 fps. Scan statistics now include country and device, and the public key can no longer read or change the legacy tables.
Reflection
I'd set the dashboard's Vercel region on day one. When I moved billing into Next.js route handlers, I wrote down cross-region latency as an accepted cost: functions in Washington, the database in Frankfurt and the Go service in Amsterdam. I only moved the functions to Frankfurt two days after the swap, when I timed a printed card from scan to card page myself and saw the legacy hop take up to 1.9 s on a cold start. It was a one-line config change.
Time the whole chain a customer goes through, from a real printed code, not only the service you own.
