A small experiment in physical gratitude. A printed card carries a QR code. When someone scans it, they land on a page that shows every person who has held that card before them — and they can leave their own name, and a note.
The card travels. The list grows. Each scan is a quiet act of presence.
┌──────────────┐ scan QR ┌─────────────────────┐ leave a name ┌──────────────┐
│ physical card│ ───────────▶ │ /c/<qr_token> page │ ───────────────▶ │ entries table│
│ (QR token) │ │ timeline + sign form│ │ (append-only)│
└──────────────┘ └─────────────────────┘ └──────────────┘
Each token page picks a random warm accent color on every load — amber, terracotta, sage, teal, mauve — so no two visits feel quite the same.
Two tables. The whole idea is an append-only log keyed by card — the
ordered entries for a card are the timeline.
| column | type | notes |
|---|---|---|
id |
uuid PK |
internal id; what foreign keys point at |
qr_token |
text unique |
random unguessable slug encoded in the QR URL |
title |
text null |
optional label, e.g. "Morning Fog on Water" |
created_at |
timestamptz |
mint time |
retired_at |
timestamptz |
soft-stop: scans rejected, history preserved |
| column | type | notes |
|---|---|---|
id |
uuid PK |
|
card_id |
uuid FK |
→ cards.id, on delete cascade |
username |
text |
free-text display name, 1–40 chars (CHECK) |
message |
text null |
optional note, ≤ 280 chars (CHECK) |
created_at |
timestamptz |
ordering key for the timeline |
Timeline query: select … from entries where card_id = $1 order by created_at asc,
served by the entries_card_created_idx index.
QR encodes a random token, not the primary key.
The URL is /c/<qr_token> where the token is a 12-char nanoid (~71 bits of
entropy). A sequential ID or UUID in the URL would let anyone enumerate every
card. The token is a capability — holding the printed card is what grants
access. The uuid PK stays internal.
Append-only entries, never updated or deleted.
A gratitude timeline is a record of moments; mutating it would let someone
rewrite history. Treating entries as an immutable log keeps the model simple
and the timeline trustworthy. "Editing" is just appending.
Soft retire instead of delete.
A printed QR can't be unprinted. retired_at lets you stop accepting new
scans on a card while keeping its full history intact. getActiveCardByToken
returns null for both unknown and retired tokens — the API never leaks
whether a card exists.
Username as free text in v1.
People self-identify by a display name. The same name may legitimately appear
across cards, and there is no auth. A users table with uniqueness would force
awkward questions ("is alex on card A the same person as alex on card B?").
Storing the name on the entry sidesteps that entirely. See Not here yet for
the upgrade path.
Postgres over Firestore.
The core operation is an ordered, relational read. Postgres wins here: CHECK
constraints enforce data quality at the storage layer, the timeline is a plain
SQL query, and relational growth (a real users table, analytics) is natural.
@vercel/postgres is also a first-class Vercel primitive. Firestore would work,
but ordering and integrity are more awkward and schema management is harder to
reason about long-term.
- Rate limiting. Anyone with the URL can POST. Before going public add a per-IP limit (Vercel KV / Upstash) and consider guarding against the same name appending twice in a row.
- Normalising usernames. To attribute timelines to real accounts later: add
a
userstable, a nullableentries.user_idFK, backfill, make it required. The free-textusernamecan stay as the displayed-at-signing value. - Moderation. A
hidden_atsoft-flag on entries would be the one exception to append-only — a hard delete is never the right move on a timeline.
Next.js 14 (App Router) · @vercel/postgres · Playfair Display + Lora via
next/font/google. Deploys to Vercel; runs locally against any Postgres.
db/
schema.sql canonical schema (source of truth)
migrations/0001_init.sql ordered, runnable migration
seed.sql demo card + sample timeline
src/
lib/db.ts @vercel/postgres client + row types
lib/cards.ts data access: resolve token, read timeline, append, mint
app/layout.tsx fonts + global styles
app/globals.css animations, hover states, CSS variable theming
app/c/[token]/page.tsx scan landing — random accent + timeline
app/c/[token]/sign-form.tsx client form component
app/api/cards/[token]/entries/route.ts GET timeline / POST entry
scripts/new-card.ts mint a card and print its QR URL
cp .env.example .env # point POSTGRES_URL at your local Postgres
pnpm install
createdb gratitude # or any Postgres instance you control
pnpm db:migrate
pnpm db:seed # optional — loads a demo card + sample timeline
pnpm dev # → http://localhost:3000/c/demo-card-001Mint a new card:
pnpm card:new "Morning Fog on Water"
# prints: /c/<token> — paste into any QR generator- Push this repo and import it in Vercel.
- Add a Postgres store (Storage → Postgres / Neon) —
POSTGRES_URLis injected automatically. - Run
0001_init.sqlonce against it (Vercel's SQL console orpsql). - Set
NEXT_PUBLIC_BASE_URLto your production domain. - Mint cards, print QR codes, set them free.