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.


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/GOA3d80uUSXVfWcwFqKg6e7yMCWuFwf8QKYidxyPbi29RxvjjUYBT5v48oiVc5Vcmikro/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:

  1. Add data interface + XLink interface to lib/data.ts, and add XLink to the Link union.
  2. Create components/X.tsx accepting { link: XLink }.
  3. Add an if (link.type === "x") branch to routes/o/[uuid].tsx with its own <Head>.
  4. Author a JSON file in data/links/.
  5. Document it here, in DATA-MODEL.md, and in COMPONENTS.md.

Run deno task check — TypeScript will point at anything you missed.