A class-based TSX runtime built on Web Components.
Mainz is for page-first apps where routing, page metadata, async loading, and hydration should stay close to the class that owns them.
The model is intentionally small:
Componentowns reusable UIPageextends that model with route concernsload()owns async datarender()stays synchronous
A Mainz component is just a class with props, optional state, and render().
import { Component, type NoProps } from "mainz";
interface CounterState {
count: number;
}
export class CounterCard extends Component<NoProps, CounterState> {
protected override initState() {
return { count: 0 };
}
override render() {
return <button>{String(this.state.count)}</button>;
}
}When a component owns async work, add load().
When a component declares load(), Mainz treats blocking as the default rendering strategy.
import { Component, type NoState } from "mainz";
interface Product {
title: string;
}
export class ProductPanel extends Component<{ slug: string }, NoState, Product> {
override async load() {
return await getProduct(this.props.slug);
}
override render(data: Product) {
return <article>{data.title}</article>;
}
}If the same component also provides placeholder(), Mainz can infer a deferred loading shape:
import { Component, type NoState } from "mainz";
interface Product {
title: string;
}
export class ProductPanel extends Component<{ slug: string }, NoState, Product> {
override async load() {
return await getProduct(this.props.slug);
}
override placeholder() {
return <p>Loading product...</p>;
}
override render(data: Product) {
return <article>{data.title}</article>;
}
}@RenderStrategy(...) is there when you want that behavior to be explicit.
Page keeps the same class model, but adds route metadata and page concerns like head().
import { Page, Route } from "mainz";
@Route("/")
export class HomePage extends Page {
override head() {
return {
title: "Hello Mainz",
};
}
override render() {
return <section>Hello from Mainz</section>;
}
}A page owns:
- route metadata
- route params
- page data
- document head
- visible output
By default, pages use csr.
That means the route is rendered on the client unless the page explicitly opts into static output.
When a route should be prerendered as static HTML, add @RenderMode("ssg"):
import { Page, RenderMode, Route } from "mainz";
@Route("/about")
@RenderMode("ssg")
export class AboutPage extends Page {
override render() {
return <section>About</section>;
}
}If the SSG route is dynamic, entries() expands the concrete params that should exist at build time:
import { Page, RenderMode, Route } from "mainz";
@Route("/docs/:slug")
@RenderMode("ssg")
export class DocsPage extends Page<{}, {}, { title: string }> {
static entries() {
return docs.map((doc) => ({
params: { slug: doc.slug },
}));
}
override async load() {
return await fetchDoc(this.route.params.slug);
}
override head() {
return {
title: this.data.title,
};
}
override render(data: { title: string }) {
return <article>{data.title}</article>;
}
}Pages own render concerns like csr or ssg.
Navigation is an app-level concern, configured through defineApp(...).
import { defineApp, startApp } from "mainz";
import { DocsPage } from "./pages/Docs.page.tsx";
import { HomePage } from "./pages/Home.page.tsx";
import { NotFoundPage } from "./pages/NotFound.page.tsx";
const app = defineApp({
pages: [HomePage, DocsPage],
notFound: NotFoundPage,
navigation: "spa",
});
startApp(app);Mainz keeps those decisions separate on purpose:
@RenderMode(...)answers how a page is rendereddefineApp({ navigation })answers how links move between pages
Navigation can be configured as:
spampaenhanced-mpa
Use DI for infrastructure like HTTP clients, API gateways, logging, and feature flags.
import { defineApp, startApp } from "mainz";
import { inject, singleton } from "mainz/di";
import { HttpClient } from "mainz/http";
class ArticlesApi {
private readonly http = inject(HttpClient);
async getBySlug(slug: string) {
return await this.http.get(`/articles/${slug}`).json<{ title: string }>();
}
}
const app = defineApp({
pages: [HomePage],
services: [
singleton(HttpClient),
singleton(ArticlesApi),
],
});
startApp(app);DI does not replace page ownership, component ownership, or semantic props.
Pages and components can declare authorization metadata with decorators such as:
@Authorize()@Authorize({ roles: [...] })@Authorize({ policy: "..." })@AllowAnonymous()
That same metadata is reusable by runtime enforcement, navigation visibility, and diagnostics.
Mainz also ships with a CLI for building apps, previewing artifacts, and validating route and framework contracts.
The most useful command is diagnose.
mainz diagnosemainz diagnose can catch issues such as:
- invalid route metadata
- unsupported page lifecycle shapes
- missing authorization policy names
- SSG-incompatible ownership patterns
- route expansion problems in
entries()
That makes it useful both in local development and in CI.
examples/authorize-siteAuthorization on pages and components with route visibility derived from the same metadata.examples/di-http-siteDI, HTTP clients, service replacement, and async page/component loading.