Concepts (read once) Mado 0.15.3
App architecture
The default starter is the canonical minimum for a Mado app: plain files, imports and Mado primitives. It deliberately does not prescribe backend-style layers or a domain-module system before the application needs them.
Start with the universal shape below. The later modular section records an optional scaling experiment, not a framework contract and not the default answer for generated applications.
Project layout (universal starter)
The default universal starter is the canonical minimum:
my-app/
├── package.json # exactly one runtime dep: @madojs/mado
├── tsconfig.json # strict TS, ES2022, Bundler resolution
├── tsconfig.node.json # Vite config type checking
├── vite.config.ts # mado() from @madojs/mado/vite
├── index.html # Vite entry + SPA shell
└── src/
├── main.ts # entry: mount router into #app
├── app.routes.ts # one app map (default + named `manifest`)
├── pages/ # route-level *.page.ts files
├── components/ # reusable custom elements
├── content/ # browser-safe content modules
└── styles/ # tokens, reset and light-DOM document CSS
index.html belongs at the project root because Vite treats it as an entry
template, not a static public file. Create public/ only when the application
has copy-as-is assets such as favicons, images or robots.txt.
The optional @madojs/ui CLI does not change the
one-runtime-dependency contract. It copies ordinary source and CSS into
configured application paths, normally under src/; applications do not
import the UI package in browser code.
The modular starter targets the same runtime but intentionally has a different
source tree: it adds layouts/, modules/ and shared/ for applications that
already need those boundaries. Its optional shape is documented separately
below; it is not the universal starter with a few empty folders filled in.
The three artifact states
| Folder | What it is | Who writes | Who reads | Deploy? |
|---|---|---|---|---|
src/ |
your TypeScript source | you | Vite, tsc --noEmit |
❌ no |
public/ |
static assets copied as-is | you | Vite build | ✅ via out/ |
out/ |
deploy artifact: assets + snapshots + SPA/404 policy | mado release |
nginx / CDN / CF Pages | ✅ yes |
One-liner: develop with mado dev, ship with mado release, then deploy
out/ with the host-specific routing policy from
Deployment.
Naming rules
| What | Style | Example |
|---|---|---|
| File | kebab-case | user-profile.ts |
| Component tag | x- + kebab |
<x-user-profile> |
| Context | PascalCase + Ctx |
ThemeCtx, AuthCtx |
| Signal | camelCase | userId, isLoggedIn |
| Route page module | kebab + .page.ts |
posts.page.ts |
What does NOT belong in src/
- ❌ Build-tool configs beyond
vite.config.tswithmado(). - ❌
.envfiles — read env insrc/shared/lib/config.tsfromimport.meta.envand import that one module everywhere. - ❌ Tests mixed with code — put them in
test/. - ❌
examples/folder — keep large demos outside the app repo.
Optional experiment: modular reference starter
The modular starter demonstrates one possible shape for a larger business
frontend with several guarded zones and a shared API policy. Adopt only the
parts that solve a problem already present in the application. A small app
does not need modules/, connectors, public barrels or ESLint boundaries.
src/
├── main.ts
├── app.routes.ts
├── layouts/
│ ├── app-shell.layout.ts
│ └── auth-shell.layout.ts
├── shared/
│ ├── http/
│ ├── lib/
│ ├── styles/
│ └── ui/
└── modules/
├── auth/
│ ├── auth.routes.ts
│ ├── auth.public.ts
│ ├── auth.service.ts
│ ├── auth.connector.ts
│ ├── auth.guard.ts
│ ├── login.page.ts
│ └── _contracts/
└── billing/
├── billing.routes.ts
├── billing.public.ts
├── billing.types.ts
├── api/
├── data/
├── pages/
├── components/
└── _contracts/
Modular app map
src/app.routes.ts is the whole application map. Modules export plain route
maps; app routes decide which shell and guard wrap each zone.
import { layout, routes } from "@madojs/mado";
import { requireAuth } from "./modules/auth/auth.public";
import { authRoutes } from "./modules/auth/auth.routes";
import { billingRoutes } from "./modules/billing/billing.routes";
export const manifest = {
"/": () => import("./modules/home/home.page"),
"/login": layout({
layout: () => import("./layouts/auth-shell.layout"),
routes: authRoutes,
}),
"/billing": layout({
layout: () => import("./layouts/app-shell.layout"),
guard: requireAuth,
routes: billingRoutes,
}),
"*": () => import("./modules/home/not-found.page"),
};
export default routes(manifest);
Rules:
- Export
manifestsomado staticcan discover pages that opt into capture. - Modules never call
layout(). - Layouts describe app zones, not domains.
- Do not hide the router inside a custom element or a second shell in
main.ts.
Optional file forms
The suffix tells you the shape:
| Suffix | Role |
|---|---|
*.page.ts |
route page, default page({...}) |
*.layout.ts |
app-zone wrapper, default page(...) |
*.connector.ts |
one external API system |
*.resource.ts |
resource() and mutation() layer |
*.service.ts |
module singleton state |
*.guard.ts |
route guard |
*.routes.ts |
module-local route map |
*.public.ts |
only public module surface |
*.types.ts |
domain types |
*.component.ts |
Web Component registration |
Page-local signals, resources and forms live inside view(). Module-wide
state lives in *.service.ts.
Optional layered data flow
shared/http/http-client.ts
▲
modules/<x>/api/*.connector.ts DTO -> domain mapping
▲
modules/<x>/data/*.resource.ts cache keys + mutations
▲
modules/<x>/pages/*.page.ts UI consumes domain types
Connectors talk to the wire and return domain types. Resources own cache keys and invalidation. Pages render and call resources/mutations.
Optional module boundaries
Each module is opaque except for <module>.public.ts. Other modules import
through that file or not at all. DTOs live under _contracts/ and stay private
to the connector that understands that external system.
The modular reference starter enforces these optional boundaries with ESLint:
- no barrels (
index.ts); - no
export *; - no CSS imports outside
src/main.ts; - no cross-module imports except public surfaces;
- no
_contractsimports outside connectors.
Styles
The starter uses plain CSS plus component-local css`...` . The split is
intentional:
| File | Role |
|---|---|
src/shared/styles/tokens.css |
design tokens as CSS custom properties |
src/shared/styles/reset.css |
document/light DOM reset |
src/shared/styles/shell.css |
app-zone layouts from src/layouts/ |
src/shared/styles/content.css |
page-level forms, tables, prose and states |
Reusable leaf components keep their own styles in css`...` inside
component() options. They should depend on tokens (var(--color-text))
rather than global classes.
Vite 8's standard CSS pipeline owns transforms and minification. Mado adds no CSS transformer; applications can configure Vite directly when they have a measured need.
Mado UI may install its theme and opt-in native CSS recipes into either style root. Treat those files as application-owned source, import only what the app uses and layer project overrides normally.
Growth
Start flat when a module is small:
modules/auth/
auth.types.ts
auth.routes.ts
auth.public.ts
auth.service.ts
auth.connector.ts
login.page.ts
When a module grows, group by role without changing suffixes:
modules/billing/
pages/
data/
api/
components/
forms/
_contracts/
If a module becomes too large, split it by subdomain and keep communication through public surfaces.
CLI
Use mado new for boring file scaffolding:
mado new module billing
mado new page billing/pages/invoices-list
mado new connector billing/api/stripe
mado new resource billing/data/invoices
mado new service billing/cart
mado new form billing/invoice
mado new component billing/components/invoice-status-badge
mado new guard billing/billing
mado new layout app-shell
The generator only writes new files. It does not edit app.routes.ts, does
not scan the filesystem, and refuses to overwrite existing files.
For reviewed UI source, use the separate registry CLI:
mado new component billing/components/invoice-status-badge
npx @madojs/ui@latest add badge
The first command creates a minimal component skeleton. The second resolves a
reviewed registry item and its dependencies, then copies them into the paths
declared by mado-ui.json. See Mado UI.
Release
Local development uses Vite through mado dev. Production uses one deploy
artifact:
mado release
rsync -avz out/ user@server:/var/www/app/
out/ is the deploy folder. dist/ is internal package output.