Data model
Complete reference for data/links/<uuid>.json. Every type here is declared in
lib/data.ts — that file is the source of truth; this document
explains it.
These files are validated at runtime.
parseLink(lib/validate.ts) checks every record before it is rendered. A record that violates the contract below is not shown as a broken page and is not a 404 — it returns a distinct500"Link Unavailable" page, with the full list of problems logged server-side.Validation is strict about required fields and lenient about unknown ones: an unrecognised field is ignored rather than rejected, because that is what a schema migration looks like. Unknown fields are also dropped, so they never reach a component.
deno task testasserts that every file indata/links/validates, so a bad file fails CI before it can reach production.
File contract
| Aspect | Rule |
|---|---|
| Location | data/links/<uuid>.json — flat directory, no nesting |
| Filename | Must equal the uuid field, plus .json |
| Encoding | UTF-8, no BOM |
| Formatting | 2-space indent (deno fmt enforces this; deno task check fails otherwise) |
| Top-level keys | Exactly four: uuid, type, config, data — all required |
Top level
{
"uuid": "sEnutKMhKwkGbZFuj3u8FyXacV3fU46DLOLafqZhWLTLhIPm4eurBsRIRbbGrzTj",
"type": "business-card",
"config": {/* LinkConfig — presentation */},
"data": {/* type-specific — content */}
}
| Field | Type | Required | Notes |
|---|---|---|---|
uuid |
string |
yes | 64 alphanumeric chars (A–Z a–z 0–9). Must match the filename. |
type |
"business-card" | "links-page" | "showcase-page" | "alpha" | "mikro" |
yes | The union discriminant. Selects how the link is served. |
config |
LinkConfig |
yes | Presentation: branding, colours, chrome. Shared by all three types. |
data |
varies by type |
yes | Content payload. Shape is fixed by type. |
The uuid field is duplicated in the filename and inside the file. The filename
is what resolves the lookup; the field is what BusinessCard uses to build the
vCard URL (/o/${link.uuid}/vcard). If they disagree, the vCard download
404s. Keep them equal.
LinkConfig — shared presentation
interface LinkConfig {
logo: string;
primaryColor: string;
website?: string;
title?: string;
footer?: string;
whatsapp?: WhatsAppConfig;
secondaryLogo?: string;
socialLinks?: SocialLink[];
locale?: string;
}
| Field | Type | Required | Format | Honoured by |
|---|---|---|---|---|
logo |
string |
yes | Root-relative URL, e.g. /logos/x.png |
all three |
primaryColor |
string |
yes | CSS colour, #rrggbb by convention |
all three |
website |
string |
no | Absolute https:// URL |
all three (wraps the header logo) |
title |
string |
no | Plain text | links-page, showcase-page |
footer |
string |
no | Plain text | links-page, showcase-page |
whatsapp |
WhatsAppConfig |
no | see below | links-page, showcase-page |
secondaryLogo |
string |
no | Root-relative URL | showcase-page only |
socialLinks |
SocialLink[] |
no | see below | showcase-page only |
locale |
string |
no | BCP-47, e.g. ar or ar-SA |
all types (drives <html lang dir>) |
phone |
string |
no | Digits only, no + |
alpha call button |
Fields set on a type that ignores them are silently discarded. Setting
secondaryLogo on a business-card does nothing; it is not an error.
logo, secondaryLogo, and block logos
Always a root-relative path beginning with /logos/, never a filesystem
path and never a bare filename. The /logos/ prefix is handled by the
serveLogos middleware (lib/assets.ts), which maps it onto
assets/logos/. So:
"logo": "/logos/onnne-demo.svg" → assets/logos/onnne-demo.svg
Recognised extensions: png, svg, jpg, jpeg, webp, gif, avif,
ico. Anything else is served as application/octet-stream, which browsers
will not render as an image — add it to CONTENT_TYPES in
lib/assets.ts if you need it.
primaryColor
The single theming knob. Applied as an inline style to the sticky header
background, icon circles, action buttons, feature checkmarks, and social icons.
Any CSS colour string works, but every existing file uses lowercase #rrggbb.
In use today: #1a1a1a (19 business cards) and #1e3a8a (the two campaign
pages).
Because it is painted behind white text and white logo plates, use a dark colour. There is no contrast checking.
WhatsAppConfig
interface WhatsAppConfig {
phone: string;
message?: string;
}
| Field | Required | Format |
|---|---|---|
phone |
yes | Digits only, country code first, no +, spaces, or dashes |
message |
no | Plain text, pre-filled into the chat (URL-encoded for you) |
Both forms work — every wa.me URL is built through normalizePhone
(lib/phone.ts), which strips +, spaces and dashes. Bare
digits remain the convention here.
Presence of this object is what renders the floating WhatsApp button; omit the key entirely to hide it.
SocialLink
interface SocialLink {
platform: string;
url: string;
}
platform selects an icon and is used as the title and React key, so it
must be unique within the array. showcase-page recognises exactly five values:
instagram · linkedin · twitter · facebook · snapchat
Anything else silently falls back to the Instagram glyph. url should be an
absolute https:// URL; links open in a new tab with
rel="noopener noreferrer".
data by type
type: "business-card" → BusinessCardData
interface BusinessCardData {
firstName: string;
lastName: string;
email: string;
phone: string;
hasWhatsapp: boolean;
position: string;
company: string;
}
All seven fields are required.
| Field | Format / notes |
|---|---|
firstName |
Rendered in the heading, the vCard FN/N, and the download filename |
lastName |
Same |
email |
Becomes a mailto: link and the vCard EMAIL |
phone |
E.164 with a leading +, e.g. +12025550100. Becomes tel: and vCard TEL. |
hasWhatsapp |
true renders the green WhatsApp button beside the phone row |
position |
Job title — subheading and vCard TITLE |
company |
Organisation — subheading, logo alt text, and vCard ORG |
Note the inconsistency with WhatsAppConfig.phone: here the + is
expected, because BusinessCard strips it when building the wa.me URL. Follow
the existing files.
, ; and \ are escaped per RFC 6350 by generateVCard, so a company name
like "Example, Inc." exports correctly.
type: "links-page" → LinksPageData
interface LinksPageData {
blocks: LinksPageBlock[];
}
interface LinksPageBlock {
icon: string;
title: string;
description: string;
action:
| { type: "link"; url: string }
| { type: "phone"; value: string }
| { type: "email"; value: string }
| { type: "whatsapp"; value: string };
}
Rendered as a two-column grid, so an even number of blocks looks best.
| Field | Notes |
|---|---|
icon |
One of the 14 names below; unknown names fall back to globe |
title |
Bold tile label — keep to 1–2 words, the tile is narrow |
description |
Muted subtitle, clamped to 2 lines (line-clamp-2) |
action |
Discriminated union; determines the href and whether it opens a new tab |
Valid icon values (from getIconPath in
components/LinksPage.tsx):
globe · phone · email · whatsapp · instagram · linkedin · twitter
· facebook · youtube · snapchat · map · download · calendar · menu
Action semantics:
action.type |
Field | Produces | Opens in new tab |
|---|---|---|---|
link |
url |
the URL verbatim | yes |
phone |
value |
tel:<value> |
no |
email |
value |
mailto:<value> |
no |
whatsapp |
value |
https://wa.me/<value, + stripped> |
no |
For phone, use E.164 with +. For whatsapp, digits only. Social platforms
here are plain link actions pointing at profile URLs — there is no dedicated
action type for them.
type: "showcase-page" → ShowcasePageData
interface ShowcasePageData {
blocks: ContentBlock[];
}
interface ContentBlock {
logo: string;
title: string;
description: string;
features?: string[];
link?: string;
linkText?: string;
}
Rendered as a responsive card grid (1 / 2 / 3 columns).
| Field | Required | Notes |
|---|---|---|
logo |
yes | Root-relative /logos/… path, shown in the card's tinted logo plate |
title |
yes | Card heading |
description |
yes | Body paragraph — one or two sentences |
features |
no | Bullet list with checkmarks tinted primaryColor; omit or [] to hide |
link |
no | Absolute URL; omit to render a card with no button |
linkText |
no | Button label; defaults to "Visit Website" when link is set |
Cards use flex flex-col with the button pinned via mt-auto, so buttons align
across a row even when descriptions differ in length.
Worked example — minimal valid file of each type
business-card
{
"uuid": "PUT_A_REAL_64_CHAR_UUID_HERE_00000000000000000000000000000000",
"type": "business-card",
"config": {
"logo": "/logos/onnne-demo.svg",
"primaryColor": "#1a1a1a",
"website": "https://onnne.link"
},
"data": {
"firstName": "Sara",
"lastName": "Ahmed",
"email": "sam@example.com",
"phone": "+12025550100",
"hasWhatsapp": true,
"position": "Product Manager",
"company": "onnne.link"
}
}
links-page
{
"uuid": "PUT_A_REAL_64_CHAR_UUID_HERE_00000000000000000000000000000000",
"type": "links-page",
"config": {
"logo": "/logos/onnne-demo.svg",
"primaryColor": "#1e3a8a",
"website": "https://onnne.link",
"title": "Let's Connect!",
"footer": "Sample data · onnne.link",
"whatsapp": { "phone": "12025550101", "message": "Hello!" }
},
"data": {
"blocks": [
{
"icon": "globe",
"title": "Visit Website",
"description": "Explore our official website",
"action": { "type": "link", "url": "https://onnne.link" }
},
{
"icon": "phone",
"title": "Call Us",
"description": "+1 202 555 0101",
"action": { "type": "phone", "value": "+12025550101" }
}
]
}
}
showcase-page
{
"uuid": "PUT_A_REAL_64_CHAR_UUID_HERE_00000000000000000000000000000000",
"type": "showcase-page",
"config": {
"logo": "/logos/onnne-demo.svg",
"secondaryLogo": "/logos/onnne-demo.svg",
"primaryColor": "#0f766e",
"website": "https://onnne.link",
"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"
}
]
},
"data": {
"blocks": [
{
"logo": "/logos/rehla-logo.png",
"title": "Rehla",
"description": "Bus transportation platform with multi-app ecosystem.",
"features": ["Driver app", "Rider app", "Real-time tracking"],
"link": "https://example.com/product",
"linkText": "Visit Rehla"
}
]
}
}
Demo records
Every link type has a public demo record, listed in
lib/demos.ts and linked from the home page. They are the
"live example" targets used throughout these docs, which is deliberate: an
example that points at a demo can never leak a customer's uuid, and it stays
clickable because a test asserts each one resolves.
Their content is onnne.link's own sample material. Contact values come from
reserved ranges — example.com (RFC 2606) and the NANP 555-01xx fiction block
— so nothing in the documentation dials or emails a real person.
Production records live alongside them in data/links/ and are not described
here; the store is deliberately opaque, since the filename is the lookup key
and the only access control.
Related
- What each type renders → LINK-TYPES.md
- How to add a file → OPERATIONS.md
- What changed and why → ROADMAP.md