Viewi in production

I wrote Viewi, and I run things on it. The site you are reading this on is one of them; nutriendoua.com is another and has been serving for years.

The newest and most demanding is Urlicer — branded short links, QR codes, team workspaces, click analytics. Its entire interface is Viewi components, server-rendered by PHP and hydrated in the browser, sharing model and validation code with the backend. The backend is Swoole; analytics land in ClickHouse. TypeScript appears in exactly one place, wrapping third-party browser SDKs that have no PHP to speak of.

Here is what the design buys you, and what it asks in return.

Per-link analytics in Urlicer: clicks over time, unique visitors, top countries and referrers
Per-link analytics. The chart, the filters and the tables are Viewi components, rendered by PHP and hydrated in place.

What the design buys

One model layer, not two

The reason the transpiler exists is not rendering — rendering could have been solved with a template engine and some JavaScript. The reason is that a component and everything it depends on, its models and its validation, are ordinary PHP classes that end up running in both places.

A validation rule written once is enforced identically in the browser and on the server, because it is the same class. The category of bug where client and server disagree about what a valid input is — the one that produces a form that submits happily and a 422 — does not exist. On a product with workspaces, invitations, quotas and per-plan limits, that is a large category to delete.

A link options form: custom alias, branded domain, UTM tags, expiry, geo targeting
One form, one set of rules. What the browser rejects and what the server rejects are the same class.

Server rendering that does not pay for its own data twice

When a component fetches while the server is rendering, the bridge re-invokes the router in-process rather than making a real HTTP request, and the response is handed to the client so the browser does not repeat it. One round trip instead of two, and no socket back to a server that is already executing the code you want.

Updates that are precise by construction

A binding records the properties it reads while it evaluates, and that record is what a change consults. There is no virtual DOM diff and no component-level invalidation: a property changes, and the bindings that actually read it update. Hydration works the same way — the runtime anchors to the server's DOM and attaches state to it rather than re-rendering over the top.

Caveats worth knowing before you write components

Assignment is what reactivity observes

State is wrapped in proxies, and a proxy intercepts access on the object it wraps. Making $this->items[] = $row reactive would mean recursively wrapping every array and object hanging off a component and re-wrapping on every write — allocation on paths that run constantly, in exchange for a mental model where some mutations are observed and others are not depending on how deep they sit. Viewi observes assignment instead, so there is one rule rather than a depth chart.

$this->items[] = $row;      // items.push(row) — nothing observes this
$this->items[$i] = $row;    // index write — likewise

The near-miss to watch for: $next = $this->items is a reference in JS rather than a copy, so assigning it back changes nothing. A genuinely new array is what re-renders.

$next = [];
foreach ($this->items as $existing) {
    $next[] = $existing;
}
$next[] = $row;
$this->items = $next;

Derived state belongs in a property

Tracking follows evaluation, which is what makes it precise, and it means a binding only knows about the reads on the path it actually took. A foreach over a property is reactive; over a method call it is not. A binding that calls a method which delegates through two or three others registers what that path read and nothing deeper. A boolean built from a chain of || stops evaluating at the first truthy operand and stops recording there too.

So compute derived values into a property where the framework can see them:

public array $gridLines = [];

public function mounted()   // props are set before mounted(), not before init()
{
    $this->refreshGrid();
    $this->watch('points', fn() => $this->refreshGrid());
}

Making this transparent would mean evaluating methods speculatively or analysing the call graph at build time. Both cost more, in build complexity and in surprising behaviour, than the idiom does. It is the trade-off in Viewi I am least comfortable with and still think is right.

Components fetch, they do not mutate

The in-process SSR fetch above has a corollary: a component whose mounted() performs a POST performs that write while the server renders a plain GET. It shows up only on a direct open of the page, because navigating to the same route client-side has the browser issue the request itself — so a page can work when you click into it and misbehave when you paste its URL.

Keep mutations in user actions. That is good practice under any framework; here it is load-bearing.

The transpile boundary is the sharp edge

Write-once means every PHP builtin used inside a component needs a faithful JavaScript counterpart, and that is a long tail to keep honest. Where a counterpart's signature drifts — a return type that is an int in PHP and a boolean in JS — you get code that is correct on the server and wrong in the browser, silently, because server rendering still produces the right HTML.

I treat those as bugs rather than documented behaviour, and I would rather make the class of problem impossible than describe it well. Until it is, the tell is worth having: if a condition holds when you check it with curl and never fires in a browser, suspect the boundary before you suspect your logic.

Where it stands

Across those sites the write-once premise has carried its weight, and the work left is not in the ambitious parts. It is at the boundary — the places where PHP and its JavaScript counterpart disagree quietly while the server keeps rendering correctly.

Docs are at viewi.net/docs, source is on GitHub.