DEV Community

Martin Palopoli
Martin Palopoli

Posted on

A native frontend for a compiled language: .fitzv components that compile to WebAssembly

Part 14 of the Fitz series. Part 13 showed the dev loop for .fitzv → WASM; this part is what a .fitzv component actually is, and how a language that emits native binaries also emits a frontend — to WebAssembly, without adopting a JavaScript framework.

The problem: a server language needs a frontend, without importing an ecosystem

Fitz compiles to a native binary and has HTTP/async/Postgres/JWT in the syntax. But a backend eventually needs a UI, and the usual answer is: bolt on the JavaScript ecosystem — a framework (React/Vue/Svelte), a bundler, a node_modules, a second toolchain, and a second definition of every type that crosses the wire.

Fitz's answer is a component format the same compiler understands, that compiles to WebAssembly — no JS framework, no npm.

A component

A .fitzv is a single file: state, events, a <template>, optional scoped styles — the Vue/Svelte shape, but it's Fitz all the way down:

component Counter {
  state {
    count: Int = 0
  }

  event inc() { count = count + 1 }
  event dec() { count = count - 1 }

  <template>
    <div class="counter">
      <button @click="dec">-</button>
      <span class="n">{count}</span>
      <button @click="inc">+</button>
    </div>
  </template>

  <style scoped>
    .counter { display: flex; gap: 12px; }
    .n { font-variant-numeric: tabular-nums; }
  </style>
}
Enter fullscreen mode Exit fullscreen mode

{count} interpolates state. @click wires an event. <style scoped> is scoped to this component (via a hash on the class names). Build it:

fitz build --bin web --target wasm-client   # → target/wasm/web/{web.js, web_bg.wasm}
Enter fullscreen mode Exit fullscreen mode

That's the whole toolchain. No package.json, no bundler config.

What the compiler emits

There's no framework runtime in the bundle. The emitter lowers the component directly to Rust over wasm-bindgen + web-sys: a struct with the state fields, a mount() that builds the DOM with create_element/append_child, event closures that mutate state, and a render() that re-paints the component's subtree on a state change. It's a self-contained WASM module — the counter above is 26 KB raw / 11.4 KB gzipped.

The template is a real DSL: interpolation ({user.name.upper()} — arbitrary Fitz expressions over state), directives ({#if} / {#for}), events (@click / @input), and composition:

<template>
  <ul>
    {#for todo in todos}
      <TodoRow label="{todo.title}" @remove="drop" />
    {/for}
  </ul>
</template>
Enter fullscreen mode Exit fullscreen mode

<Child prop="v" /> passes props down; @remove="drop" bubbles an event up with its payload; <slot> lets a parent inject content. Composition works across files, so a component library is just imports.

The type is the same type

This is the part a bolted-on framework can't give you. The type your server defines is the exact one your component uses — imported from the same .fitz module:

// models.fitz
type Todo {
  id: Int
  title: Str
  done: Bool
}
Enter fullscreen mode Exit fullscreen mode
// App.fitzv
from models import Todo
// ... a List<Todo> in state, {todo.title} in the template
Enter fullscreen mode Exit fullscreen mode

The server compiles Todo into a native struct; the component compiles it into a WASM struct; there's one definition. No types.ts that drifts from the backend, no codegen step to keep them in sync — by construction they can't diverge.

Why this is different

Component models aren't new — the point is what compiles them:

  • React / Vue / Svelte are excellent, but they're JavaScript: a framework runtime shipped to the browser, a bundler, an npm tree, and a separate type story for the API boundary. .fitzv compiles to WASM with none of that.
  • Elm is a lovely own-language frontend, but it compiles to JavaScript and lives only in the browser — it isn't the same language as your compiled backend.
  • Rust WASM frameworks (Dioxus, Leptos) compile to WASM too, but you're adopting a framework, its macro DSL, and its toolchain on top of Rust. In Fitz the component is the language, and the same fitz binary builds it.

One language, one compiler, one type definition — emitting a native binary for the server and a WASM bundle for the browser.

The honest edges (MVP)

  • Re-render is naive: a state change re-paints the component's subtree, not a fine-grained signal graph. It's fast enough for the common case; fine-grained reactivity is a future slice.
  • The template supports a growing envelope of constructs (interpolation, {#if}/{#for}, events, composition, slots, scoped styles); genuinely exotic patterns fall outside it for now and the compiler tells you where.
  • The client is WASM-first; there's no JS-vanilla target — a deliberate choice recorded when the counter came in under the 40 KB bundle gate.

That's the frontend as a first-class part of the language: components you write in Fitz, compiled to WebAssembly, sharing types with a backend compiled to native code. Next up: how a function on that backend becomes callable from the component as if it were local — @rpc.


Fitz is a compiled language with gradual typing and HTTP/async/DB as first-class citizens, compiling to native binary via Rust. Its frontend is .fitzv single-file components compiled to WebAssembly — no framework, no npm. Open source.

Top comments (0)