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 incomponents/(the exceptions are Alpha node definitions andcomponents/builder/, both of which render only inside an island). They will compile, render, and silently do nothing. This has already bitten this codebase once:ErrorDisplayshipped a "Go Back" button whoseonClicknever hydrated, so it rendered, styled, hovered and did nothing.tests/render_test.tsnow 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<State>"]
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:
new App<State>()is created.Statecarrieslocaleanddir(utils.ts), whichroutes/_app.tsxrenders onto<html>.app.use(staticFiles())serves everything understatic/— the three favicon files, plus the per-document asset folders Alpha records point at.- A middleware sets the
Statedefaults,enandltr. The link route overrides them per link fromconfig.locale. 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 aCONTENT_TYPESmap (png,svg,jpg,jpeg,webp,gif,avif,ico), and returns it withCache-Controland anETag— answering304to a matchingIf-None-Match. On any read failure it falls through toctx.next(), so a missing logo becomes a normal 404 rather than a 500.app.fsRoutes()maps theroutes/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:
- The uuid is validated first, against
^[A-Za-z0-9]{64}$, before any disk access. A malformed uuid isnot-found, because a bad URL is not a bad record. This also keeps unvalidated input out of a path interpolation. - 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.
- The file is parsed and validated by
parseLinkfromlib/validate.ts. Failures are logged with the full list of problems and returned asinvalid.
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 -Aruns 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.
10. Where to read next
- Adding or editing a link → OPERATIONS.md
- Authoring one through a form → BUILDER.md
- The JSON schema → DATA-MODEL.md
- What each page type renders → LINK-TYPES.md
- Known bugs and technical debt → ROADMAP.md