Architecture

How onnne.link is put together, and why.

1. What the system is

onnne.link serves smart links: single-purpose, mobile-first showcase pages addressed by an opaque 64-character slug at /o/<uuid>. Each page is reached by scanning a QR code or tapping an NFC tag. There are five kinds of link — a digital business card, a link hub, a multi-block product page, a client-authored Alpha document, and a mikro app hosted as-is — all driven by one JSON file per link, committed to this repository.

There is no database, no admin UI, no user accounts, and no client-side application. A link is a file; publishing a link is a deploy.

2. Stack

Layer Choice Version Where declared
Runtime Deno 2.9.6
Web framework Fresh (JSR @fresh/core) ^2.3.3 deno.json
View library Preact ^10.27.2 (10.29.x) deno.json
Build tool Vite + @fresh/plugin-vite ^7.1.3 / ^1.0.8 vite.config.ts
CSS Tailwind CSS (v4, CSS-first) ^4.1.10 assets/styles.css
Testing @std/assert + deno test ^1.0.0 deno.json
QR generation qrcode-svg (npm) 1.1.0 components/QRCode.tsx
Animation @lottiefiles/lottie-player (CDN) 2.0.4 components/ErrorDisplay.tsx

qrcode-svg ships no type declarations, so they live in lib/qrcode-svg.d.ts and are attached at the import site with a @ts-types directive.

3. Rendering model: server-first, two islands

business-card, links-page and showcase-page ship zero JavaScript. They are rendered to HTML on the server and shipped as-is — a business card scanned at a conference should paint instantly on a poor mobile connection, and none of them needs stateful interactivity. All "interaction" is plain hyperlinks: tel:, mailto:, https://wa.me/…, a download attribute, and CSS hover:/active: states.

The one exception among link pages is alpha, and only when its stored document contains an interactive node. Then a single shared island (islands/alpha/AlphaRuntime.tsx) mounts and re-renders the validated node tree client-side. An Alpha page of pure content ships nothing, like the rest. See ALPHA.md.

The second island is not a link page at all: islands/builder/Builder.tsx is the authoring tool at /builder, which is a form and cannot be anything but interactive. It is confined to that route — Vite keeps the two islands in separate chunks, so no /o/<uuid> response gained a byte from it. See BUILDER.md.

Two consequences worth internalising before you change anything:

  • You cannot use onClick, useState, useEffect, or signals in components/ (the exceptions are Alpha node definitions and components/builder/, both of which render only inside an island). They will compile, render, and silently do nothing. This has already bitten this codebase once: ErrorDisplay shipped a "Go Back" button whose onClick never hydrated, so it rendered, styled, hovered and did nothing. tests/render_test.ts now fails if a <button> reappears there.
  • To add real interactivity you must create a Fresh island in islands/ and import it from a route.

The single exception is lottie-player, a custom element loaded from unpkg by components/ErrorDisplay.tsx — its only consumer — through <Head>. It is a self-contained web component, registered via a <script type="module"> tag, so it works without Fresh hydration. It is declared to TypeScript in types.d.ts and added to jsxPrecompileSkipElements so the precompiler leaves the tag alone.

It used to load from routes/_app.tsx, i.e. on every page. Scoping it to the one component that needs it means an ordinary link page makes no outbound request, and the home page — which embeds five link pages in iframes — no longer pulls it six times.

4. Request lifecycle

flowchart TD
  A["GET /o/:uuid"] --> B["main.ts - App&lt;State&gt;"]
  B --> C["staticFiles()"]
  C --> S["state defaults: locale, dir"]
  S --> D{"pathname starts with /logos/ ?"}
  D -->|yes| E["path guard + Deno.readFile + ETag"]
  D -->|no| F["ctx.next()"]
  E -->|"file missing"| F
  F --> G["app.fsRoutes()"]
  G --> H["routes/o/[uuid].tsx handler"]
  H --> I["getLinkByUuid(uuid)"]
  I --> J{"status"}
  J -->|not-found| K["404 - Link Not Found"]
  J -->|invalid| V["500 - Link Unavailable"]
  J -->|ok| L{"link.type"}
  L -->|business-card| M["BusinessCard + QRCodeSVG"]
  L -->|links-page| N["LinksPage"]
  L -->|showcase-page| O["ShowcasePage"]
  M --> P["200 HTML"]
  N --> P
  O --> P

Step by step, in main.ts:

  1. new App<State>() is created. State carries locale and dir (utils.ts), which routes/_app.tsx renders onto <html>.
  2. app.use(staticFiles()) serves everything under static/ — the three favicon files, plus the per-document asset folders Alpha records point at.
  3. A middleware sets the State defaults, en and ltr. The link route overrides them per link from config.locale.
  4. serveLogos (lib/assets.ts) intercepts /logos/*, rejects any path containing .., a leading /, a backslash or a NUL, reads the file, looks the content type up in a CONTENT_TYPES map (png, svg, jpg, jpeg, webp, gif, avif, ico), and returns it with Cache-Control and an ETag — answering 304 to a matching If-None-Match. On any read failure it falls through to ctx.next(), so a missing logo becomes a normal 404 rather than a 500.
  5. app.fsRoutes() maps the routes/ tree to URLs.

Full route table: ROUTING.md.

5. Data layer

lib/data.ts exposes one function, and it is deliberately source-agnostic:

getLinkByUuid(uuid): Promise<
  | { status: "ok"; link: Link }
  | { status: "not-found" }
  | { status: "invalid"; errors: string[] }
>

The three-way result is the important part. "No such link" and "this link exists but its record is broken" are different conditions with different causes and different fixes, so they get different status codes and different pages, see ROUTING.md.

What happens on a lookup:

  1. The uuid is validated first, against ^[A-Za-z0-9]{64}$, before any disk access. A malformed uuid is not-found, because a bad URL is not a bad record. This also keeps unvalidated input out of a path interpolation.
  2. The cache is consulted, keyed by uuid and invalidated by file mtime, so editing a JSON file still takes effect on the next request with no restart.
  3. The file is parsed and validated by parseLink from lib/validate.ts. Failures are logged with the full list of problems and returned as invalid.

parseLink takes an unknown and touches no filesystem, which is what makes it reusable: the same validation guards a JSON file today and a database row later. It is strict about required fields and lenient about unknown ones, because an unrecognised field is what a schema migration looks like.

Properties worth knowing before extending this:

  • Paths are relative to the process CWD, so the server must be started from the repository root. This is why deno serve -A runs from the project directory.
  • The cache sits behind the loader, not in the route, so a database-backed implementation can substitute its own strategy without touching callers.
  • There is no index. No way to list all links; the filename is the lookup key.

6. Type hierarchy

Link is a discriminated union on the type field. Every link shares a LinkConfig (presentation) and carries a type-specific data payload (content).

classDiagram
  class Link {
    <<union>>
  }
  class LinkConfig {
    +string logo
    +string primaryColor
    +string? locale
    +string? website
    +string? title
    +string? footer
    +WhatsAppConfig? whatsapp
    +string? secondaryLogo
    +SocialLink[]? socialLinks
  }
  class BusinessCardLink {
    +uuid
    +type = "business-card"
    +config: LinkConfig
    +data: BusinessCardData
  }
  class LinksPageLink {
    +uuid
    +type = "links-page"
    +config: LinkConfig
    +data: LinksPageData
  }
  class ShowcasePageLink {
    +uuid
    +type = "showcase-page"
    +config: LinkConfig
    +data: ShowcasePageData
  }
  Link <|-- BusinessCardLink
  Link <|-- LinksPageLink
  Link <|-- ShowcasePageLink
  BusinessCardLink *-- LinkConfig
  LinksPageLink *-- LinkConfig
  ShowcasePageLink *-- LinkConfig

Because the union is discriminated, the if (link.type === "…") chain in routes/o/[uuid].tsx narrows link.data to the right shape in each branch, and each component accepts only its own variant. Adding a fourth type is therefore a compile-time-checked operation — see OPERATIONS.md.

Field-by-field reference: DATA-MODEL.md. Rendering semantics per type: LINK-TYPES.md.

7. Component tree

flowchart TD
  App["routes/_app.tsx — html/head/body"]
  App --> Index["routes/index.tsx — marketing"]
  App --> Uuid["routes/o/[uuid].tsx — dispatcher"]
  App --> E404["routes/_404.tsx"]
  App --> E500["routes/_500.tsx"]
  Uuid --> BC["BusinessCard.tsx"]
  Uuid --> LP["LinksPage.tsx"]
  Uuid --> WP["ShowcasePage.tsx"]
  Uuid --> ED1["ErrorDisplay.tsx — link not found"]
  E404 --> ED2["ErrorDisplay.tsx"]
  E500 --> ED3["ErrorDisplay.tsx"]
  BC --> QR["QRCode.tsx — QRCodeSVG(): string"]
  LP --> WA["WhatsAppButton.tsx (shared)"]
  WP --> WA
  LP --> IC["icons.ts (shared ICON_PATHS)"]
  WP --> IC
  Uuid --> SM["SocialMeta.tsx - og/twitter tags"]

Note that QRCode.tsx is not a component — QRCodeSVG() returns an SVG string, which the route passes to BusinessCard and which is injected with dangerouslySetInnerHTML. The floating WhatsApp button and the SVG icon map are shared modules — they were once duplicated per component with behaviour that had quietly diverged.

Per-component detail: COMPONENTS.md.

8. Directory map

onnne.link/
├── main.ts              App wiring only: middleware composition + fsRoutes
├── client.ts            Client entry — imports styles.css for HMR only
├── utils.ts             State (locale/dir), the `define` helper, directionFor()
├── types.d.ts           JSX declaration for the <lottie-player> custom element
├── deno.json            Imports, tasks, lint rules, JSX/compiler options
├── vite.config.ts       Vite plugins: fresh() + tailwindcss()
├── routes/              File-system routes (see ROUTING.md)
│   ├── _app.tsx         HTML shell
│   ├── _404.tsx         Not-found page
│   ├── _500.tsx         Server-error page
│   ├── index.tsx        Marketing / "coming soon" landing page
│   ├── builder/         The link builder (see BUILDER.md)
│   │   ├── index.tsx    307 to the first link type
│   │   ├── [type].tsx   The authoring page, one per link type
│   │   ├── preview.tsx  POST-only: renders a draft record
│   │   └── schema.ts    The node vocabulary as JSON
│   └── o/
│       ├── [uuid].tsx   The product: loads a link, dispatches on type
│       └── [uuid]/
│           └── vcard.ts GET handler returning a .vcf download
├── components/          Preact components (see COMPONENTS.md)
│   ├── LinkPageView.tsx Head + type dispatch, shared by /o/ and the preview
│   └── build/           The builder's stateless form controls
├── islands/             Two islands, both scoped to one feature
│   ├── alpha/AlphaRuntime.tsx
│   └── build/Builder.tsx
├── lib/
│   ├── data.ts          Types + getLinkByUuid() + the link cache
│   ├── errors.ts        Shared validation primitives
│   ├── alpha/  Node registry: validation, rendering, editor schema
│   ├── build/           Builder helpers: paths, templates, uuid, node seeds
│   ├── assets.ts        serveLogos middleware for /logos/*
│   ├── mikro/serve.ts   serveMikro middleware + entry-document reader
│   ├── validate.ts      parseLink(): source-agnostic runtime validation
│   ├── phone.ts         normalizePhone(), waMeUrl()
│   ├── site.ts          siteOrigin() / absoluteUrl() - SITE_ORIGIN aware
│   └── vcard.ts         generateVCard(): RFC-escaped vCard 3.0
├── data/links/          One <uuid>.json per link — the content store
├── docs/                This documentation — also rendered at /docs
├── mikro/               Hosted apps — their own toolchain, excluded from Deno tooling
│   └── demo/            Vanilla TS + Vite, built to dist/, served as-is
├── tests/               The entire test suite (see DEPLOYMENT.md)
│   ├── app_test.ts      HTTP contract: statuses, headers, path guards
│   ├── render_test.ts   HTML snapshots of every page type
│   ├── *_test.ts        Unit tests for lib/ and components/
│   └── __snapshots__/   Committed snapshot output
├── assets/
│   ├── styles.css       @import "tailwindcss";
│   └── logos/           Brand images served at /logos/*
├── .github/workflows/   CI: check, test, build
├── static/              Served at / by staticFiles() — favicons, Alpha assets
└── _fresh/              Build output (gitignored) — never edit

9. Design decisions and trade-offs

Flat JSON files instead of a database. Content lives in git: changes are reviewable, diffable, and revertible, and there is no database to operate or back up. The cost is that publishing or editing a link requires a commit and a redeploy, non-technical staff cannot self-serve, and nothing validates a file before it reaches production.

/builder softens the last two without changing the store: a record can be assembled through a form and seen rendered before it exists, validated by the same parseLink that guards the served files. Publishing is still a commit.

Opaque UUID as the access control. A 64-character alphanumeric slug (~380 bits of space) is unguessable in practice, so a link is effectively private-by-URL without any auth system. The trade-off is that this is only obscurity: anyone with the URL has permanent access, there is no revocation short of deleting the file and redeploying, and there is no expiry, rate limiting, or audit trail.

Zero client-side JavaScript. Instant paint on mobile networks, nothing to hydrate, nothing to break. The cost is that interactive features require introducing the first island, and that a component author can write an event handler that silently no-ops.

Per-link theming via inline styles. config.primaryColor is applied through style={{ backgroundColor: … }} rather than Tailwind classes, because Tailwind cannot generate classes for values that only exist at runtime. This keeps theming fully data-driven at the cost of losing Tailwind's variant system for those properties. See STYLING.md.

Server-rendered QR codes. The QR SVG is generated per request rather than stored as an asset, so it can never drift from the URL it encodes. That URL comes from siteOrigin() (lib/site.ts), which defaults to the production origin and is overridable with SITE_ORIGIN.