Meta Mado 0.15.3
Mado for Backend Developers
You write in Go / Rust / .NET / Java / Python and you need to build a web UI for an admin panel, internal tool or dashboard.
This page explains Mado in backend-oriented terms.
The Main Analogy
Mado is structured like an HTTP server. Seriously:
| Server world | Mado |
|---|---|
| HTTP router (chi, axum, mux) | routes() — path manifest |
Handler func(req, resp) |
page({ view: (ctx) => html`...` }) |
| Middleware | layout() route group (wraps the handler) |
| Template engine (Jinja, Handlebars) | html`` tagged template |
| HTTP client with cache | resource() — fetch + cache + invalidation |
| Reactive variable / atom | signal() — reactive getter |
| Background goroutine / task | effect() — auto-reruns when a signal changes |
defer cleanup() |
ctx.onDispose(fn) in component setup |
| ENV variables | createContext() + provide()/inject() |
If you understand an HTTP server, you understand Mado.
File Structure — like a regular application
src/
├── routes.ts ← path manifest (like router.go in chi)
├── main.ts ← entry point (like main.go: setup + run)
├── pages/ ← one file per page (like handler.go)
├── components/ ← reusable UI (like helpers/)
├── layouts/ ← wrappers for groups of pages (like middleware/)
└── lib/ ← business logic, API client (like service/, repo/)
One file = one page. No file-based magic routing — everything is declared by hand in routes.ts.
Hello World — server analogy
Go (chi) — for comparison
r := chi.NewRouter()
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("<h1>Hello</h1>"))
})
r.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
fmt.Fprintf(w, "<h1>User %s</h1>", id)
})
http.ListenAndServe(":8080", r)
Mado — the same thing
// src/app.routes.ts
import { routes } from "@madojs/mado";
export default routes({
"/": () => import("./pages/home.js"),
"/users/:id": () => import("./pages/user.js"),
});
// src/modules/<module>/pages/home.ts
import { page, html } from "@madojs/mado";
export default page({
view: () => html`<h1>Hello</h1>`,
});
// src/modules/<module>/pages/user.ts
import { page, html } from "@madojs/mado";
export default page<{ id: string }>({
view: ({ params }) => html`<h1>User ${params.id}</h1>`,
});
Path parameters are available in params — just like chi.URLParam.
Signals — a reactive variable
If you've written Erlang/Elixir with Agent, or Rust with Arc<Mutex<T>>, or
simply stored state in a struct and updated it, a signal is the same kind of
state holder plus subscriptions. Passing a signal (or a getter that reads it)
to a template slot makes that slot update automatically.
import { signal, effect } from "@madojs/mado";
// "variable" with subscription
const count = signal(0);
// read
console.log(count()); // 0
// write
count.set(5);
// "goroutine" that runs on every change
effect(() => {
console.log("count is now", count());
});
// → will print "count is now 5"
count.set(10);
// → will print "count is now 10"
No rules like "can't use inside a condition". A signal is just a getter function. Wherever it is read — that's where the subscription is created.
resource() — HTTP client with cache (like cache.GetOrSet)
This is the most useful abstraction for a backend developer. It's like Redis with automatic invalidation, but in the browser.
import { resource, mutation, jsonFetcher, invalidate } from "@madojs/mado";
// "user repository"
const userId = signal(1);
const user = resource(
() => `/api/users/${userId()}`, // cache key (reactive!)
jsonFetcher<User>(), // how to load
{ staleTime: 60_000 }, // 60-second cache
);
// in the component:
user.data(); // User | undefined
user.error(); // Error | null
user.loading(); // boolean
// mutation (like POST/PUT)
const save = mutation<User, User>(
(u) =>
fetch("/api/users", { method: "POST", body: JSON.stringify(u) }).then((r) =>
r.json(),
),
{ invalidates: ["/api/users*"] }, // glob invalidation — like `cache.Drop("users:*")`
);
await save.run(newUser);
// automatically: user.data() will update if glob matches
Resource keys are cache identities. Include the endpoint, query params and data
shape in the key: two live resource() calls with the same key share cached
data and any in-flight request. If two different fetchers use the same in-flight
key, Mado warns because that usually means the cache key is too broad.
If such an abstraction existed in the Go world for server-side caches — we'd all be crying with joy.
Components = handler with its own memory
A component is a handler that renders its piece of UI. It has:
- parameters (attributes/properties);
- internal state (
signals); - lifecycle:
connectedCallback(like Init),disconnectedCallback(like Close).
import { component, html, signal } from "@madojs/mado";
component("x-counter", () => {
const count = signal(0);
return html`
<button @click=${() => count.update((n) => n + 1)}>Clicks: ${count}</button>
`;
});
Usage:
html`<x-counter></x-counter>`;
We register the <x-counter> tag in the browser — it becomes a "function" that can be inserted into HTML. This is a native browser mechanism (Web Components), Mado only glues it together with signals.
Forms — like form.Validate() on the backend
Mado uses the browser's constraint validation and adds reactive state tracking.
import { useForm } from "@madojs/mado";
const f = useForm({
initial: { email: "", age: "" as number | "" },
});
// in the template:
html`
<form
@submit=${f.onSubmit(async (v) => {
await api.save(v);
f.reset();
})}
>
<input
name="email"
type="email"
required
.value=${() => f.values().email ?? ""}
@input=${f.onInput}
@blur=${f.onBlur}
/>
${() =>
f.errors().email && f.touched().email
? html`<small>${f.errors().email}</small>`
: null}
<input name="age" type="number" min="18" @input=${f.onInput} />
<button type="submit" ?disabled=${() => !f.isValid() || f.submitting()}>Save</button>
</form>
`;
Custom validation — validate: (values, { signal }) => errors | null. No Yup schemas or dependencies.
Context = DI / dependency injection
Just as you pass context.Context through the call stack in Go — in Mado context is propagated through the DOM tree.
import { createContext, provide, inject } from "@madojs/mado";
// declare the "type" of the dependency
const ApiCtx = createContext<ApiClient>(defaultApiClient);
// in the root component — provide
component("x-app", ({ host }) => {
provide(host, ApiCtx, new ApiClient("https://api.example.com"));
return html`<x-page />`;
});
// in any child — consume
component("x-page", ({ host }) => {
const api = inject(host, ApiCtx); // signal<ApiClient>
return html`<div>API version: ${() => api().version}</div>`;
});
This is like context.WithValue / ctx.Value in Go, but reactive.
The transport is the interoperable Web Components context-request event, so
providers and consumers can cross library boundaries.
SEO — not SSR, but static snapshots (like templ generate in Go)
If you're used to server-side rendering for SEO, in Mado this is solved differently: browser-rendered prerender at build time.
// src/modules/<module>/pages/product.ts
export default page({
static: {
paths: () => api.allProductSlugs(), // build-time fetch
initialData: ({ slug }) => api.getProduct(slug),
},
head: (_, data) => ({
description: data.description,
canonical: `/product/${data.slug}`,
og: { title: data.name, image: data.image },
}),
view: ({ params }) => html`<x-product data-slug=${params.slug} />`,
});
npm run release # → out/product/iphone-15/index.html (+ sitemap)
The crawler sees ready-made HTML with meta tags. The user sees the same thing + interactivity after JS loads.
More details: 15-static-snapshots.md.
Typical backend developer tasks — recipes
CRUD page with a list
import { page, html, resource, each, signal } from "@madojs/mado";
export default page({
view: () => {
const users = resource(() => "/api/users", jsonFetcher<User[]>());
return html`
${() => (users.loading() ? html`<p>Loading…</p>` : null)}
${() =>
users.error() ? html`<p>Error: ${users.error()!.message}</p>` : null}
<ul>
${() =>
each(
users.data() ?? [],
(u) => u.id,
(u) => html`
<li><a href="/users/${u.id}" data-link>${u.name}</a></li>
`,
)}
</ul>
`;
},
});
Form with POST
import { useForm, mutation } from "@madojs/mado";
const createUser = mutation<NewUser, User>(
(u) =>
fetch("/api/users", { method: "POST", body: JSON.stringify(u) }).then((r) =>
r.json(),
),
{ invalidates: ["/api/users*"] },
);
// in page.view:
const f = useForm({ initial: { name: "" } });
html`
<form
@submit=${f.onSubmit(async (v) => {
await createUser.run(v);
navigate("/users");
})}
>
<input name="name" required @input=${f.onInput} />
<button type="submit">Create</button>
</form>
`;
Protected zone (auth middleware)
// src/layouts/auth-layout.ts
import { page, html, effect } from "@madojs/mado";
import { isAuthed, navigate } from "../lib/auth.js";
export default page({
view: ({ child }) => {
effect(() => {
if (!isAuthed()) navigate("/login");
});
return html`<div class="app-shell">${child}</div>`;
},
});
// src/app.routes.ts
import { layout, routes } from "@madojs/mado";
export default routes({
"/login": () => import("./pages/login.js"),
"/app": layout({
layout: () => import("./layouts/auth-layout.js"),
routes: {
"/dashboard": () => import("./pages/dashboard.js"),
"/users": () => import("./pages/users.js"),
},
}),
});
Shared HTTP client (like a small transport package in Go)
// src/shared/http/http-client.ts
export const httpClient = {
get: <T>(path: string): Promise<T> =>
fetch(path).then((r) => r.json() as Promise<T>),
};
Module connectors build on this transport and map DTOs to domain types.
What you do not need to learn (good news)
- Hooks and hook rules. Not in Mado. Signals are ordinary functions.
- VDOM and reconciliation. None. Signals update the DOM directly, surgically.
- Webpack/Babel configs. Vite is the transport; the starter ships one
three-line
vite.config.tswithmado(). useEffectdependency arrays.effect()sees what you read on its own.- State management libraries (Redux/Zustand). Signals + context.
- CSS-in-JS transformations. Shadow DOM +
css``+ cssVars. - A framework-specific router migration ladder. The router is split into focused source modules with an explicit public contract.
What you will need to learn (honestly)
These are new concepts. Not scary, but they are additions to your React/Vue base:
- Custom Elements / Shadow DOM.
<x-foo>is not a div; it is a full-fledged element with its own DOM. Review the MDN model for slots, scoped CSS and the custom-element lifecycle. attributevsproperty. Attribute is a string in HTML (data-id="5"), property is a JS property (el.id = 5).?attr=${flag}and.prop=${value}in templates refer to different things. Main rule: numbers/objects/arrays — via.prop, flags — via?attr, strings — viaattr.- Signals. They are getter functions with automatic dependency tracking; the important new habit is passing a getter, rather than an already-read value, into a reactive template slot.
html``-templates. It's just a JS function with highlighting via lit-plugin. Not magic.
Everything else — standard browser + TypeScript.
What's missing (honestly)
- The built-in overlay covers runtime inspection; a browser extension remains an evidence-driven backlog item rather than a release promise.
- No hosted playground is part of the framework contract; the local starter is the canonical runnable example.
- General-purpose AI assistants have seen far less Mado code than mainstream
frameworks. Give them
AGENTS.mdorllms.txt, then verify the relevant source and tests when a contract is unclear.
Further reading
12-routing.md— the router in detail.16-app-architecture.md— project structure.15-static-snapshots.md— SEO without SSR.17-mado-ui.md— reviewed UI source without another runtime.- madojs.dev — the production framework dogfood site.
starters/default/— the canonical local end-to-end example.
If something is unclear, open an issue or inspect the focused source module and its tests.