Skip to content

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.ts with mado().
  • useEffect dependency 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:

  1. 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.
  2. attribute vs property. 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 — via attr.
  3. 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.
  4. 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.md or llms.txt, then verify the relevant source and tests when a contract is unclear.

Further reading

If something is unclear, open an issue or inspect the focused source module and its tests.