Skip to main content
← Back to blog

How we built qraffic

qraffic turns a printed QR code into a short link whose destination you can change later. Here is how the redirect engine, the scan pipeline and the Postgres data model fit together.

by

engineeringarchitectureqr-codes

qraffic is built and run by Cuanto Labs, a software studio in Fort Lauderdale, Florida. It makes dynamic QR codes: each code encodes a short link, a server resolves that link to a destination you can edit at any time, and every scan is recorded for analytics. The resolution step picks a destination by targeting rule, A/B variant or default, and answers with a 302 redirect before any logging work runs.

The problem

A static QR code bakes its destination into the image. If a printed menu, flyer or package points at a URL that later changes, the print run is wrong forever. Static codes also tell you nothing about who scanned them.

The fix is indirection. The code encodes /r/<slug>, a short link on our domain, and the server decides where that slug goes today. That indirection is the whole product, and it creates one hard requirement: the redirect must be fast and must never fail, because the code is already printed and the person holding the phone is not our customer and cannot fix anything.

Architecture

qraffic redirect architectureA phone scan hits the redirect route, which reads a Redis cache backed by Postgres, selects a destination, and returns a 302. A deferred step then writes the scan event to Postgres.Phone scan/r/[slug] routeselect + 302Redis cachePostgres (Neon)after(): scanevent + counter

The route lives in src/app/r/[slug]/route.ts on the Node runtime and is marked force-dynamic, because every scan is a unique event. It does five things in order:

  1. Resolve the slug to a code, plus its variants or rules, through getResolution() in src/lib/qr/cache.ts. That reads Upstash Redis first and falls back to Postgres, then backfills the cache with a 120 second TTL. If Redis is absent or errors, the request still succeeds from Postgres.
  2. Build a scan context from the geo headers the host platform sets, plus the User-Agent, in src/lib/qr/request-meta.ts.
  3. Call selectDestination() in src/lib/qr/routing.ts.
  4. Append any incoming UTM parameters to the destination and return a 302 with cache-control: no-store.
  5. Inside Next.js after(), bump a Redis live counter and write the scan event.

Step 5 is the reason logging cannot slow a redirect, and it is also the reason a logging failure cannot break one.

Data model

The core is four tables in src/db/schema/qr.ts: qr_codes (one row per code, with kind, payload, routing mode, style JSON and a denormalised total_scans counter), qr_variants (A/B destinations with a weight), qr_rules (ordered targeting rules whose match criteria live in a JSON column) and scan_events (one row per scan).

All four are scoped by account_id with Postgres row-level security. Dashboards query through a role that sees only the signed-in account's rows. The public redirect path has no user session, so it uses a separate admin client that bypasses RLS, and isolation there is structural: lookups are keyed by the globally unique slug and writes carry the account_id read off the code. The authenticated role gets SELECT only on scan_events, so a signed-in user can read scans but can never insert one.

selectDestination() is a pure function. It takes the code, variants, rules, a scan context, a random number and a scan index, and returns a URL. Because it has no I/O it is unit tested directly. The order is fixed: in rules mode the first enabled rule by position whose match passes (country, region, device, OS, language or a time window in an IANA timezone), then A/B variants (weighted random or round-robin), then the default destination.

Trade-offs

Best-effort logging. Deferring the write to after() keeps the redirect at one cache read plus one pure function. The cost is that a failed write loses a scan row. We accepted that: a missed analytics row is cheaper than a broken printed code. recordScan() in src/lib/qr/scan.ts also uses two sequential writes instead of db.batch, because the same code path has to run on the Neon HTTP driver in production and on node-postgres locally, and node-postgres has no batch.

Privacy by hashing. We never store a raw IP address. visitor_hash is sha256(ip | user agent | code id), stable per visitor per code so unique scans can be counted. ip_hash is sha256(ip | date), a coarse daily abuse signal. Geo comes from edge headers, so no cookie is needed. The trade-off is that visitor_hash is a persistent pseudonymous identifier within one code, which is what makes unique counts possible.

Quota without breaking codes. src/lib/qr/quota.ts enforces each plan's monthly scan allowance (1,000 on free, 10,000 on starter, 100,000 on pro and 500,000 on business, as of October 2026 in src/config/plans.ts). Redirects are never blocked on any plan. Past the cap, plans without metered overage stop writing the detail row but the aggregate counter keeps moving, so owners can tell "no scans" from "scans I can no longer see". Plans with overage keep logging and report one Stripe meter event per started block of 10,000 scans, fire and forget, so a slow billing call never touches the scan path.

Rotation mode derives its round-robin index from total_scans on the cached row, which keeps the engine stateless but makes rotation approximate. Weighted random is the mode to choose when proportions matter.

Rendering codes

Images come from src/lib/qr/qr-image.ts. The styling studio (gradients, custom dot shapes, logo overlay) renders in the browser with qr-code-styling. The server path, used by the REST API and bulk CSV export, supports colors, margin and error correction. Its PNG encoder is a small palette-based writer on Node's native zlib; the code comments measure about 1.3 ms against 7.5 ms per 512 pixel image for the qrcode package's default encoder, and roughly 13 seconds against 75 seconds for a 10,000 code bulk run. When a logo is present, error correction defaults to level H so the code still scans.

What's next

The repo's own planning notes list the open items. Overage billing on annual subscriptions needs a decision, because the metered Stripe price is monthly and Stripe requires matching billing periods. A unique constraint on plan names was deliberately deferred. The monetization notes also record known gaps against the category: no mobile app and no integrations marketplace.

FAQ

What is a dynamic QR code?
A dynamic QR code encodes a short link instead of the final destination. The short link redirects through a server, so the destination can be edited after the code is printed and every scan can be counted.
Does qraffic store visitor IP addresses?
No. The redirect handler derives two one-way SHA-256 hashes from the request and stores those. The raw IP address is never written to the database, and no cookies are set.
What happens to a printed code if an account goes over its scan quota?
The redirect keeps working on every plan. Past the monthly allowance, plans without metered overage stop writing the per-scan detail row while the code's scan counter keeps counting. Plans with metered overage keep logging detail and are billed per block of 10,000 scans.
How does qraffic choose between targeting rules and A/B variants?
Each code has one routing mode: single, ab or rules. In rules mode the first enabled rule that matches, in position order, wins. In ab mode a variant is picked by weighted random or round-robin. If nothing matches, the code's default destination is used.
What is qraffic built with?
Next.js, TypeScript, Drizzle ORM on Neon Postgres, Upstash Redis and QStash, Stripe for billing, and Neon Auth for sign-in.