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
- Put it in
mikro/<slug>/with its own toolchain intact. Copyingmikro/demo/is the quickest start — it is about sixty lines and has no framework. - Add a build line to the
mikro:buildtask using--base=/m/<slug>/. - Write a record with
{ "type": "mikro", "data": { "app": "<slug>" } }. 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.