Mikro

The mikro link type: onnne.link hosts an independently built frontend application instead of rendering a page for it.

Why it exists

The other types describe a page and onnne.link renders it. That works when the page is content. It does not work when the page is a bespoke product — a specific header that changes on scroll, a stepper rail with connecting lines, a ticket-stub banner with corner notches. Reproducing those from a generic vocabulary produces something structurally similar and visually wrong, and no amount of extra props closes the gap.

Mikro stops translating and starts hosting. The app keeps its own repository layout, framework, dependencies and build; onnne.link contributes the uuid URL and the record. What a visitor receives is the app's build output, so it cannot drift from its source — there is nothing in between to drift.

Record

{
  "uuid": "<64 alphanumeric characters>",
  "type": "mikro",
  "config": {/* the usual metadata */},
  "data": { "app": "demo" }
}

app names a directory under mikro/ and is constrained to ^[a-z0-9][a-z0-9-]*$ — a record cannot express a path, so it cannot reach outside that directory. config is still validated and still carries the metadata, but the body is the app's, so config.title and the social tags are not used (see the trade-offs below).

Layout

mikro/
  demo/                  a minimal vanilla-TypeScript app — the template to copy
    package.json         its own dependencies, whatever they are
    vite.config.ts
    src/
    dist/                build output (gitignored)

Each app keeps its own toolchain. The two in the repository deliberately use different stacks — the demo is plain TypeScript, the other is React — because that is the property worth demonstrating: onnne.link neither knows nor cares which framework produced the output it serves.

There is a live demo of this type on the home page, served from mikro/demo/.

Nothing in mikro/ is onnne.link's code. deno.json excludes the directory from fmt, lint and check, which matters more than it sounds: an early version of this work ran deno fmt before that exclusion existed and silently reformatted all 20 of the app's source files. The exclusion is what keeps "unmodified" true, and there is a test that the source matches upstream.

Serving

Two touch points, and deliberately only two.

Request Response
GET /o/<uuid> mikro/<app>/dist/index.html, byte for byte, Cache-Control: no-cache
GET /m/<app>/* the matching file from that app's dist/

The route branch returns a raw Response and never reaches the page component, so routes/_app.tsx does not wrap it and none of onnne.link's shell, CSS or markup enters the document.

no-cache on the entry document is not incidental. Vite's deployment guidance is explicit: without it "the old assets will be still referenced" after a redeploy, because the browser keeps a stale index.html pointing at hashed files that no longer exist. Hashed assets under dist/assets/ get the opposite treatment — immutable, since their name changes when their content does.

Content types come from @std/media-types rather than a hand-written map, because a real application ships .js, .css, .woff2, .webp and .mp4, not just images.

Building

deno task mikro:build

mikro:build is a thin fan-out — one subtask per app, so a new app is one line and each keeps whatever install and build steps it actually needs:

"mikro:build":       "deno task mikro:build:demo && deno task mikro:build:<other>",
"mikro:build:demo":  "cd mikro/demo && pnpm install && pnpm exec vite build --base=/m/demo/",

--base is the one thing that has to be right. Vite rewrites "JS-imported asset URLs, CSS url() references, and asset references in your .html files" to sit under it, which is what lets the app be mounted at a path it knows nothing about. It is keyed on the app, not the link, so one app can back several links and a rebuild never depends on a record.

Note that the subtasks invoke vite directly rather than the app's own build script. Where that script chains commands — tsc -b && vite build is common — appending --base does not reach vite. It is a subtle failure, because the build still succeeds and only the asset paths come out wrong.

An app whose dist/ is missing returns the standard 500 "Link Unavailable", not a 404 and not a blank page: the record is fine, the deployment is not, and those are different faults.

Deployment

Deno Deploy is configured from the repository rather than the dashboard, via the deploy key in deno.json:

"deploy": {
  "framework": "fresh",
  "install": "deno install",
  "build": "deno task mikro:build && deno task build"
}

Two things to know about that block.

It is all-or-nothing. Deploy's documentation is explicit that if any deploy key is present, "the entire configuration will be sourced from the file instead of the dashboard" — and once a build has succeeded using it, the dashboard fields are locked. Adding only build would not work; framework (or runtime) has to be there too. The app directory is the one setting that remains dashboard-only.

The builder runs Node through Deno. Deploy provides node, npm, pnpm and yarn, but "all JavaScript inside of the builder is executed using Deno" and node is "a shim that translates Node.js invocations to deno run". That was worth verifying rather than assuming: building this app under Deno's npm compatibility instead of Node produces byte-identical output, so the shim is not a problem.

One consequence to expect. If the builder's installer does not honour pnpm-lock.yaml, it may resolve a different patch release of Vite than a local build does — 8.1.5 rather than the pinned 8.2.0, in one observed run. The output is still internally consistent, because index.html references the hashes from the same build; only the hashes differ between environments. Nothing depends on them matching.

Note also that dist/ is gitignored, so a deployment that skips the build step serves the 500 page and logs has no built index.html. If that ever happens, committing dist/ is the fallback — about 17 MB, but no build step at all.

Adding an app

  1. Put it in mikro/<slug>/ with its own toolchain intact. Copying mikro/demo/ is the quickest start — it is about sixty lines and has no framework.
  2. Add a build line to the mikro:build task using --base=/m/<slug>/.
  3. Write a record with { "type": "mikro", "data": { "app": "<slug>" } }.
  4. deno task mikro:build, then load the link.

The app needs no changes to work here. The proof of concept still fetches /api/contact-config, an endpoint that does not exist on onnne.link — every consumer already falls back to a hardcoded number, so the page works and only a 404 appears in the console. Porting that endpoint is optional.

Trade-offs, recorded deliberately

Same origin. A mikro app's JavaScript runs on onnne.link's origin, with the access that implies — storage, cookies, same-origin requests. That is acceptable while apps are first-party, as the proof of concept is.

It stops being acceptable the day a third-party repository is accepted. MDN is unambiguous: "same-origin embedding is a layout/document-scoping tool, not a security boundary", and a sandboxed iframe does not fix it — allow-scripts together with allow-same-origin lets the framed document remove its own sandbox attribute, leaving it "no more secure than not using the sandbox attribute at all". Untrusted apps must be served from a separate origin.

The record format does not change when that happens, so this is a decision to revisit rather than a corner to be painted into.

No Open Graph injection. The app's own <head> is served untouched, so link previews come from whatever it declares. The record's title and image are ignored for mikro links. This was chosen for absolute fidelity; injecting tags would mean rewriting the HTML on every request.

Repository weight. An app brings its source assets with it — the proof of concept adds roughly 17 MB. dist/ is gitignored and rebuilt in CI.