Keeping Your Email and Phone Off a Public Link Page Until Someone Asks
A QR link page that never ships private contact details in its HTML or JavaScript. Server-only values, a short request form, only the asked-for methods returned, and a 24-hour signed re-reveal.
The problem: a QR code is public, a phone number should not be
A link page behind a QR code on a card or a slide will be opened by anyone, and anything in its HTML can be scraped. I wanted rukon-link, my page at link.rukon.dev, to list social profiles openly while keeping my email, phone number and WhatsApp off the page entirely, released only when a person asks. This article is for anyone building the same in Next.js: where the values live, how a request releases them, and what this protects against and what it does not.
The values never reach the client bundle
The private details exist only as server-side environment variables, PRIVATE_EMAIL, PRIVATE_PHONE and PRIVATE_WHATSAPP_NUMBER, without the NEXT_PUBLIC_ prefix that would inline them into the browser bundle. The module that reads them starts with import "server-only", so if a client component ever imported it, the build would fail rather than quietly ship the values.
The public page renders three buttons, "Get my Email", "Phone" and "WhatsApp", and nothing else. There is no obfuscated address in the markup and no hidden element to scrape. If you view the page source, the values are simply not there.
/** Private contact details. Only ever sent to a browser after a successful contact request. */
export function privateContact(method: ContactMethod) {
const email = read("PRIVATE_EMAIL");
// ...
switch (method) {
case "email":
return email ? { method, label: "Email", value: email, href: `mailto:${email}` } : null;
// phone and whatsapp build tel: and wa.me links the same way
}
}Asking for them
A visitor opens a short form: name, email, an optional phone number and message, which methods they want, and a consent tick box. POST /api/contact-request validates everything on the server, applies spam checks and a rate limit described in their own article, and stores the request. It then returns only the methods the visitor asked for. Asking for an email address does not also reveal the phone number.
- 01Visitor taps Get my Email / Phone / WhatsApp
- 02Form: name, email, methods, consent
- 03Server validates, checks for spam, rate-limits
- 04Request stored for the admin inbox
- 05Response: only the requested details, plus a 24-hour token
The database enforces the shape too: the requests table has a CHECK that at least one of the three methods was requested, and length limits on every text column, so a request that bypassed the form's validation still could not store nonsense.
Every request lands in a private admin inbox, where it can be filtered and marked new, contacted or handled, or deleted. That is half the point. I know who asked for what, and when.
Seeing them again without asking twice
Someone who closes the sheet and opens it again a minute later should not have to fill in the form again. The response carries a signed token, stored in the visitor's localStorage, that POST /api/contact-access will honour for 24 hours. The token's payload is the request id, the list of methods that request covered, and an expiry; the signature is an HMAC-SHA256 under a server secret, compared in constant time. Because the methods are inside the signed payload, a token issued for an email request cannot be edited to reveal the phone number.
The token mechanics, splitting, recomputing the signature, comparing lengths before timingSafeEqual and then checking the expiry, are the same pattern I used for a CV download, explained in Gating a CV Download Behind a Server-Signed Grant in Next.js. An expired or altered token gets a 401 and the client discards it.
What this protects against, and what it does not
It stops passive harvesting. Scrapers, link previewers and anyone reading the page source get no contact details, and bulk collection now requires filling in a form, passing the spam checks and staying within a rate limit of three requests per ten minutes and ten per day per network.
It is not a verification step. The email address a visitor enters is not confirmed, so a determined person can type any address and receive the details. The design accepts that: the goal is friction against automated collection and a record of who asked, not secrecy from a human who wants to contact me. Those are different problems, and a link page for a QR code only needs to solve the first.
Notifications are also not built: new requests appear only in the admin inbox. The project's status and limitations are on the rukon-link project page.
The work behind this
Related
Keep reading
Contact Form Spam Protection Without a CAPTCHA, and Its Honest Limits
A honeypot, a minimum fill time, a same-site check, strict validation and a database-backed rate limit on a Next.js contact form. What each layer stops, and which ones a script can walk past.
Gating a CV Download Behind a Server-Signed Grant in Next.js
A public portfolio, a CV with a phone number in it, and a public repository. How this site releases three contact fields and one PDF only after a form, without a database and without trusting the browser.
First-Party Analytics Without Cookies: A Daily-Rotating Salted IP Hash
Page views and link clicks recorded by the site itself, with no third-party tracker and no cookies. A salted IP hash that changes daily, beacons that survive navigation, and what is still stored.