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.
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.
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.