Link types
Every type below has a live demo linked from the home page and registered in
lib/demos.ts; the "live example" links point at those.
There are five, discriminated by the type field. This document covers what
each one is for, what it renders, and how it behaves. For the field-level
schema see DATA-MODEL.md.
All five are dispatched from a single route,
routes/o/[uuid].tsx, which loads the link and
picks how to answer:
mikro → return the app's own index.html as a raw Response
business-card → <BusinessCard link={link} qrCodeSvg={…} />
links-page → <LinksPage link={link} />
showcase-page → <ShowcasePage link={link} />
alpha → <AlphaPage link={link} doc={doc} />
mikro is answered in the handler rather than by a page component, so it never
reaches _app.tsx and nothing of onnne.link's shell enters the document.
At a glance
business-card |
links-page |
showcase-page |
|
|---|---|---|---|
| Purpose | One person's contact card | Link hub for an org | Campaign / product showcase |
| Component | BusinessCard.tsx |
LinksPage.tsx |
ShowcasePage.tsx |
| Shell width | md:max-w-md (phone-shaped) |
md:max-w-md |
max-w-7xl (full width) |
| Content unit | fixed fields | blocks[] of tiles |
blocks[] of cards |
| Grid | — | 2 columns | 1 / 2 / 3 columns |
| QR code | yes, server-rendered | no | no |
| vCard download | yes, /o/<uuid>/vcard |
no | no |
| Floating WhatsApp button | no | if config.whatsapp |
if config.whatsapp |
Uses config.title |
no (uses person's name) | yes | yes |
Uses config.footer |
no (fixed "Powered by") | yes | yes |
Uses config.secondaryLogo |
no | no | yes |
Uses config.socialLinks |
no | no | yes |
Page <title> |
First Last - Company |
config.title |
config.title |
1. business-card
A digital business card for one person, designed to be handed over by NFC tap or QR scan at an event and saved straight to the recipient's phone.
Live example:
/o/sEnutKMhKwkGbZFuj3u8FyXacV3fU46DLOLafqZhWLTLhIPm4eurBsRIRbbGrzTj
JSON
{
"uuid": "sEnutKMhKwkGbZFuj3u8FyXacV3fU46DLOLafqZhWLTLhIPm4eurBsRIRbbGrzTj",
"type": "business-card",
"config": {
"logo": "/logos/onnne-demo.svg",
"primaryColor": "#1a1a1a",
"website": "https://onnne.link"
},
"data": {
"firstName": "Alex",
"lastName": "Rivera",
"email": "alex@example.com",
"phone": "+12025550100",
"hasWhatsapp": true,
"position": "Product Designer",
"company": "onnne.link"
}
}
Layout
┌─────────────────────────────┐
│ sticky header, primaryColor│ h-28 mobile / h-40 desktop
│ ┌───────────────┐ │ logo on a white rounded plate
│ │ company logo │ │ wrapped in <a> if config.website
│ └───────────────┘ │
├─────────────────────────────┤
│ First Last │ text-2xl bold
│ Position │
│ Company │
│ │
│ ┌───┐ +1 202… ┌────────┐ │ phone row; WhatsApp pill only
│ │📞 │ │ 💬 │ │ when hasWhatsapp is true
│ └───┘ └────────┘ │
│ ┌───┐ name@example.com │ email row → mailto:
│ │✉ │ │
│ └───┘ │
│ ───────────────────────── │
│ ┌───────────────┐ │
│ │ QR CODE │ │ 160×160, server-generated SVG
│ └───────────────┘ │
├─────────────────────────────┤
│ ⬇ Save Contact │ primaryColor; fixed to the
└─────────────────────────────┘ viewport bottom on mobile
Powered by onnne.link desktop only (hidden md:block)
On mobile the card fills the screen edge-to-edge and the Save Contact button
is fixed bottom-0 so it is always reachable with a thumb; the scroll area gets
pb-16 to clear it. From the md breakpoint up, the card becomes a centred
max-w-md sheet with rounded corners and a shadow, and the button returns to
normal flow.
Behaviour
| Element | Action |
|---|---|
| Header logo | Opens config.website in a new tab, if set; otherwise inert |
| Phone number | tel:+1202… — dials on mobile |
| WhatsApp pill | https://wa.me/966… (the + is stripped), new tab |
| Email row | mailto:… |
| QR code | Static image — encodes this page's own URL, for phone-to-phone share |
| Save Contact | Downloads First Last.vcf from /o/<uuid>/vcard |
The QR code
Generated on the server, per request, in
routes/o/[uuid].tsx:
const pageUrl = `https://onnne.link/o/${uuid}`;
const qrCodeSvg = QRCodeSVG({ url: pageUrl, size: 160 });
QRCodeSVG is a plain function returning an SVG string (not a component);
the string is passed to BusinessCard and injected with
dangerouslySetInnerHTML. Parameters are fixed: 160 px, padding 1, foreground
#1a1a1a, background #ffffff, error-correction level M.
The origin comes from siteOrigin() (lib/site.ts) and
defaults to production, so a QR rendered locally still points at the live link
unless you set SITE_ORIGIN.
The vCard flow
Save Contact links to /o/<uuid>/vcard, handled by
routes/o/[uuid]/vcard.ts. The handler
re-loads the link, refuses any type other than business-card with a 404,
and returns:
Content-Type: text/vcard
Content-Disposition: attachment; filename="First_Last.vcf"
generateVCard (lib/vcard.ts) emits vCard 3.0 with CRLF line
endings:
BEGIN:VCARD
VERSION:3.0
N:Rivera;Alex;;;
FN:Alex Rivera
ORG:onnne.link
TITLE:Product Designer
TEL;TYPE=CELL:+12025550100
EMAIL:alex@example.com
END:VCARD
Values are escaped per RFC 6350 — , ; \ and newlines — so a company name
like "Example, Inc." survives the round trip into a contacts app.
Note the two filenames differ: the download attribute on the anchor asks for
First Last.vcf (space) while the server sends First_Last.vcf (underscore).
The server's Content-Disposition wins for a same-origin navigation.
Not included: URL (even when config.website is set) and PHOTO. Both were
considered and declined, because adding them changes what every existing user
downloads.
2. links-page
A link hub — the "one link in bio" pattern — for an organisation rather than a person. A grid of labelled icon tiles, each firing one action.
Live example:
/o/l1CnAFKeAAnw74bb1sLSpBrNjICIE2Aw697zw0FNnGsCFcgPBQdXUFfd5ZO14cp2
JSON (abridged)
{
"type": "links-page",
"config": {
"logo": "/logos/onnne-demo.svg",
"primaryColor": "#1e3a8a",
"title": "Let's Connect!",
"footer": "Sample data · onnne.link",
"whatsapp": {
"phone": "12025550101",
"message": "Hello! Coming From the Links Page"
}
},
"data": {
"blocks": [
{
"icon": "globe",
"title": "Visit Website",
"description": "Explore our official website",
"action": { "type": "link", "url": "https://onnne.link" }
},
{
"icon": "whatsapp",
"title": "WhatsApp",
"description": "Chat with us",
"action": { "type": "whatsapp", "value": "12025550101" }
}
]
}
}
The live file has 9 blocks: website, phone, email, WhatsApp, Google Maps location, Facebook, Twitter, Instagram, LinkedIn.
Layout
┌─────────────────────────────┐
│ sticky header, primaryColor│ same shell as business-card
│ ┌───────────────┐ │
│ │ logo │ │
│ └───────────────┘ │
├─────────────────────────────┤
│ config.title │ centred, text-2xl bold
│ │
│ ┌────────┐ ┌────────┐ │ grid-cols-2, gap-3
│ │ (🌐) │ │ (📞) │ │ icon in a primaryColor circle
│ │ Title │ │ Title │ │
│ │ descr. │ │ descr. │ │ line-clamp-2
│ └────────┘ └────────┘ │
│ ┌────────┐ ┌────────┐ │
│ │ (✉) │ │ (💬) │ │
│ └────────┘ └────────┘ │
├─────────────────────────────┤
│ config.footer │ only if set
└─────────────────────────────┘
┌────┐
│ 💬 │ fixed FAB, bottom-right,
└────┘ only if config.whatsapp
Two fixed columns at every breakpoint — the grid does not reflow — inside the
same md:max-w-md phone-shaped shell as the business card. Tiles have
hover:shadow-md and active:scale-95 feedback.
Behaviour
Each tile is a single <a>. The href comes from the block's action:
action.type |
href |
Target |
|---|---|---|
link |
action.url |
_blank |
phone |
tel:${action.value} |
same tab |
email |
mailto:${action.value} |
same tab |
whatsapp |
https://wa.me/${value without "+"} |
same tab |
Only link actions open a new tab (and get rel="noopener noreferrer"); the
three scheme actions must stay in the same tab so the OS handler takes over.
The floating WhatsApp button appears whenever config.whatsapp exists,
independently of whether any block also has a whatsapp action. It appends
?text= when config.whatsapp.message is set.
Icons
getIconPath maps a name to a hardcoded SVG path string. 14 names are
available:
globe · phone · email · whatsapp · instagram · linkedin · twitter
· facebook · youtube · snapchat · map · download · calendar · menu
Unknown names fall back to globe silently. The map itself is shared with
ShowcasePage via components/icons.ts; only the
fallback differs, and it is passed in by the caller.
3. showcase-page
A full-width marketing page: a header with up to two logos, an optional social bar, and a responsive grid of product cards. Built for exhibition/campaign QR codes where a visitor should see a portfolio at a glance.
Live example:
/o/xFvFjozh1yDIEoDZ3MZplLuF9ZD1iaAbvxnC6zhLqrzpSDNJ1DH3a01ibHD0Lxn6 — three
product cards showing the three card shapes.
JSON (abridged)
{
"type": "showcase-page",
"config": {
"logo": "/logos/onnne-demo.svg",
"secondaryLogo": "/logos/onnne-demo.svg",
"primaryColor": "#0f766e",
"title": "onnne.link Showcase",
"footer": "Sample data · onnne.link",
"whatsapp": {
"phone": "12025550101",
"message": "Hello! I scanned the QR code"
},
"socialLinks": [
{ "platform": "instagram", "url": "https://example.com/instagram" },
{
"platform": "linkedin",
"url": "https://example.com/linkedin"
},
{ "platform": "twitter", "url": "https://example.com/x" },
{ "platform": "facebook", "url": "https://example.com/facebook" }
]
},
"data": {
"blocks": [
{
"logo": "/logos/rehla-logo.png",
"title": "Rehla",
"description": "Bus transportation platform with multi-app ecosystem for drivers, riders, station operators, and administrators",
"features": [
"Multi-app platform",
"Driver app",
"Rider app",
"Real-time tracking"
],
"link": "https://example.com/product",
"linkText": "Visit Rehla"
}
]
}
}
Layout
┌───────────────────────────────────────────────────────────┐
│ sticky header, primaryColor │ z-20
│ ┌──────────┐ ┌──────────┐ │
│ │ logo │ │ secondary│ │ justify-between
│ └──────────┘ └──────────┘ │ secondary optional
├───────────────────────────────────────────────────────────┤
│ sticky social bar, white (only if socialLinks) │ z-10, top-[72px]
│ ⓘ in 𝕏 f │ md:top-[88px]
├───────────────────────────────────────────────────────────┤
│ config.title │ text-3xl/4xl
│ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ 1 / 2 / 3 cols
│ │ [logo] │ │ [logo] │ │ [logo] │ │
│ │ Title │ │ Title │ │ Title │ │
│ │ descr. │ │ descr. │ │ descr. │ │
│ │ ✓ feature│ │ ✓ feature│ │ ✓ feature│ │
│ │ ✓ feature│ │ ✓ feature│ │ ✓ feature│ │
│ │ [ Visit ] │ │ [ Visit ] │ │ [ Visit ] │ │ pinned via mt-auto
│ └───────────┘ └───────────┘ └───────────┘ │
├───────────────────────────────────────────────────────────┤
│ config.footer │ dark bg-slate-900
└───────────────────────────────────────────────────────────┘
┌────┐
│ 💬 │ fixed FAB
└────┘
Two stacked sticky bars. The social bar's top-[72px] / md:top-[88px] offsets
are hardcoded to match the header's height — if you change the header
padding or logo size, these offsets must change with it or the bars will
overlap.
Behaviour
| Element | Action |
|---|---|
| Main logo | Opens config.website in a new tab, if set |
| Secondary logo | Never a link — displayed only |
| Social icons | Open social.url in a new tab, tinted primaryColor |
| Card button | Opens block.link in a new tab; label is linkText or "Visit Website" |
| Floating button | wa.me chat, if config.whatsapp |
Cards without a link render with no button. Cards without features skip the
list.
Icons
Drawn from the same shared ICON_PATHS
(components/icons.ts), but only five names are
meaningful here: instagram · linkedin · twitter · facebook · snapchat.
Unknown platforms fall back to instagram.
4. alpha
A page authored as data rather than built from fixed fields, so a client can
publish one without repository access and without a deploy. data holds a node
tree with semantic props; the node registry owns validation, rendering, and the
schema a future editor will generate its property panels from.
Unlike the three types above, the shape of the page is not fixed — the record describes it. That is the whole point, and it is a large enough subject to have its own document: ALPHA.md.
{
"type": "alpha",
"config": {
/* the same metadata as every other type, plus optional `phone` */
},
"data": {
"schemaVersion": 1,
"locales": ["en", "ar"],
"defaultLocale": "en",
"doc": { "type": "root", "children": [/* nodes */] }
}
}
It is also the only type that can ship JavaScript, and only when its document contains an interactive node. A page of pure content is server-rendered like everything else.
5. mikro
An independently built application, hosted rather than rendered. data names a
directory under mikro/; onnne.link serves that app's build output byte for
byte and contributes nothing to the document.
Live example:
/o/GOA3d80uUSXVfWcwFqKg6e7yMCWuFwf8QKYidxyPbi29RxvjjUYBT5v48oiVc5Vc —
mikro/demo/, a vanilla-TypeScript Vite app with no framework, hosted as-is.
Use it when the page is a bespoke product rather than content — the other four types describe a page, and no vocabulary reproduces a custom design faithfully. Full detail: MIKRO.md.
{
"type": "mikro",
"config": {/* metadata; the body is the app's, so title/OG are unused */},
"data": { "app": "demo" }
}
Adding a sixth type
The union makes this a compile-time-guided change. In order:
- Add
datainterface +XLinkinterface tolib/data.ts, and addXLinkto theLinkunion. - Create
components/X.tsxaccepting{ link: XLink }. - Add an
if (link.type === "x")branch toroutes/o/[uuid].tsxwith its own<Head>. - Author a JSON file in
data/links/. - Document it here, in DATA-MODEL.md, and in COMPONENTS.md.
Run deno task check — TypeScript will point at anything you missed.