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 distinct 500 "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 test asserts that every file in data/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.

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.

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.