The browser-side reference implementation: three runtime packages under
client/web/packages/ consumed in the browser, plus two server-side codegen
packages under server/typescript/packages/ that emit code targeting those
runtimes. Together they wire React + TanStack Query + TanStack Table to any
backend that speaks the MetaObjects REST contract.
Throughout this doc the worked example is an Author entity in the
acme::blog package (the same Author shape used across docs/features/).
This document covers the TypeScript client tier: the three browser
packages (@metaobjectsdev/runtime-web, @metaobjectsdev/react,
@metaobjectsdev/tanstack) plus the two server-side codegen packages that
target them (@metaobjectsdev/codegen-ts-react,
@metaobjectsdev/codegen-ts-tanstack). The client is universal: it can
consume any backend (TypeScript / Java / Kotlin / C# / Python) that
implements the REST URL grammar and JSON wire format defined in
features/api-contract.md.
Each browser-facing framework integration ships as a pair of packages —
one server-side (codegen, runs at meta gen time) and one browser-side
(runtime, runs in the user's app). This mirrors Prisma
(prisma + @prisma/client), Apollo (@apollo/codegen-cli + @apollo/client),
and Drizzle (drizzle-kit + drizzle-orm).
Runtime side (browser): Codegen side (server):
@metaobjectsdev/runtime-web ←──┐ @metaobjectsdev/codegen-ts ←──┐
↑ \ ↑ \
├── @metaobjectsdev/react │ ├── @metaobjectsdev/codegen-ts-react
│ ↑ │ │
└── @metaobjectsdev/tanstack┘ └── @metaobjectsdev/codegen-ts-tanstack
(depends on codegen-ts-react)
Two disjoint dependency trees. The codegen packages live under
server/typescript/packages/ because they execute server-side (Node, during
meta gen), even though their output targets the browser. The runtime
packages live under client/web/packages/ and have zero Node-only deps.
Angular follows the same two-package pattern and exists in-repo, source-only by decision — see "Angular 18" below. Future framework integrations (Svelte, React Native) will follow the same pattern.
| Package | Purpose | Key exports |
|---|---|---|
@metaobjectsdev/runtime-web |
Pure framework-agnostic browser core. Zero React, zero TanStack, zero Node-only deps. | formatCurrency, parseCurrency, minorUnitsFor, buildFilterQs, type EntityFetcher, type GridConfig |
@metaobjectsdev/react |
React-specific runtime (peer-deps on react, react-hook-form, @hookform/resolvers, zod). |
useEntityForm, <CurrencyInput>, types EntityMeta, EntityFieldMeta, BoundInputProps |
@metaobjectsdev/tanstack |
TanStack runtime (peer-deps on @tanstack/react-query, @tanstack/react-table). |
<EntityFetcherProvider>, useEntityFetcher, <EntityGrid>, <CellRendererProvider>, defaultCellRenderers |
| Package | Generators | What it emits |
|---|---|---|
@metaobjectsdev/codegen-ts-react |
formFile() |
<Entity>.form.tsx — a per-entity React form using useEntityForm + <CurrencyInput> |
@metaobjectsdev/codegen-ts-tanstack |
tanstackQuery(), tanstackGrid(), tanstackGridHook() |
<Entity>.hooks.ts (5 React Query hooks) for every entity; <Entity>.columns.tsx (TanStack Table column defs) and <Entity>.grid.ts (the controlled grid state hook) for entities declaring a layout.dataGrid |
Both codegen packages emit imports that target their matching runtime
package. The framework-neutral @metaobjectsdev/codegen-ts engine remains the
substrate (entity files, query helpers, server routes, barrel).
The minimal client-aware config registers entity + queries + routes +
forms + tanstack hooks + grids + barrel. The apiPrefix value flows into
both the route registration (server-side) and the generated hooks'
fetch URLs (browser-side):
// metaobjects.config.ts
import { defineConfig } from "@metaobjectsdev/cli";
import {
entityFile,
queriesFile,
routesFile,
barrel,
} from "@metaobjectsdev/codegen-ts/generators";
import { formFile } from "@metaobjectsdev/codegen-ts-react";
import { tanstackQuery, tanstackGrid, tanstackGridHook } from "@metaobjectsdev/codegen-ts-tanstack";
export default defineConfig({
outDir: "packages/database/src/generated",
dialect: "postgres",
apiPrefix: "/api",
columnNamingStrategy: "snake_case",
generators: [
entityFile(),
queriesFile(),
routesFile(),
formFile(),
tanstackQuery(),
tanstackGrid(),
tanstackGridHook(), // pairs with tanstackGrid — generates the controlled grid state
barrel(),
],
});For projects that want entities/routes/hooks emitted into different
packages, use the targets registry (see "Per-target output directories"
below).
Every generated hook (React Query) calls useEntityFetcher(), which reads
a single EntityFetcher function from React context. The consumer's app
supplies the concrete implementation — auth headers, base URL, error
handling — and the same fetcher serves every entity.
// from @metaobjectsdev/runtime-web
export type EntityFetcher = <T>(path: string, init?: RequestInit) => Promise<T>;// In the consumer's app root:
import { EntityFetcherProvider } from "@metaobjectsdev/tanstack";
const fetcher = async <T,>(path: string, init?: RequestInit): Promise<T> => {
const res = await fetch(path, {
...init,
credentials: "include",
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
});
if (!res.ok) throw new Error(`HTTP ${res.status} on ${path}`);
return res.status === 204 ? (undefined as T) : ((await res.json()) as T);
};
export function App() {
return (
<EntityFetcherProvider value={fetcher}>
{/* generated hooks now have a fetcher */}
<AuthorList />
</EntityFetcherProvider>
);
}The URL grammar this fetcher must speak is defined in
features/api-contract.md — GET /api/author?...,
POST /api/author, etc.
formFile() emits a <Entity>.form.tsx per entity. The form imports
useEntityForm (React Hook Form bound to a generated Zod insert schema)
and exposes a .input.<field> accessor for each field. Spread it onto an
<input> element — every metadata-derived attribute (placeholder, type,
aria-label, RHF rules) rides along automatically.
Metadata:
Generated Author.form.tsx (consumer's view):
// generated/acme/blog/Author.form.tsx (excerpt)
import { useEntityForm } from "@metaobjectsdev/react";
import { Author, AuthorInsertSchema } from "./Author";
export function AuthorForm({ onSubmit }: { onSubmit: (v: AuthorInsert) => void }) {
const form = useEntityForm(Author, AuthorInsertSchema);
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<input {...form.input.name} />
<textarea {...form.input.bio} />
<button type="submit">Save</button>
</form>
);
}useEntityForm returns the full React Hook Form UseFormReturn<T> surface
plus .input — so handleSubmit, formState, setValue, etc. are all
available. Validation runs through zodResolver against the generated
AuthorInsertSchema.
tanstackQuery() emits <Entity>.hooks.ts per entity — 2 query hooks for
projections (view-backed, read-only) and 5 hooks for full writable
entities:
| Hook | Verb / Path |
|---|---|
useAuthor(id) |
GET /api/author/:id |
useAuthors(filter?) |
GET /api/author?filter[...]=...&sort=...&limit=N&offset=N |
useCreateAuthor() |
POST /api/author |
useUpdateAuthor() |
PATCH /api/author/:id |
useDeleteAuthor() |
DELETE /api/author/:id |
Each hook is React Query–native: query hooks return UseQueryResult,
mutation hooks return UseMutationResult. Mutations aggressively
invalidate authorKeys.all() so lists re-fetch after writes.
Hooks are emitted for every entity; grid artifacts are not. tanstackGrid()
emits <Entity>.columns.tsx only for an entity that declares a layout.dataGrid
child — declaring one is how you say "this entity is displayed in a grid". Wiring
the grid generators and getting no grid files means exactly that, and meta gen
reports it in its warnings.
Each layout.dataGrid yields a <entity><Grid>Columns (TanStack Table column
defs, each carrying meta.view so the renderer registry can look up its formatter)
plus a <entity><Grid>Grid (the GridConfig). The grid's name capitalizes into
both, so "name": "default" on Author gives authorDefaultColumns +
authorDefaultGrid.
<EntityGrid> is fully controlled: beyond columns/grid/data it needs
rowCount, a state object and three onChange callbacks. tanstackGridHook()
generates that plumbing (state + the withCount=1 query) as
use<Entity><Grid>Grid(), returning exactly the prop shape <EntityGrid> wants —
so wire all three generators and the page is three lines:
import { EntityGrid } from "@metaobjectsdev/tanstack";
import { authorDefaultColumns, authorDefaultGrid } from "./generated/acme/blog/Author.columns";
import { useAuthorDefaultGrid } from "./generated/acme/blog/Author.grid";
export function AuthorList() {
const grid = useAuthorDefaultGrid(); // owns sorting/pagination/filters + the query
return <EntityGrid {...grid} columns={authorDefaultColumns} grid={authorDefaultGrid} />;
}Owning that state yourself is supported — drive useAuthors({ sort, limit, offset, withCount: 1 }) from your own useState and pass data, rowCount, state and
the three onChange callbacks by hand — but the hook exists so you don't have to.
A layout.dataGrid child on an entity declares the column order, default
sort, and page size for the generated TanStack Table column file. Column
keys must reference fields defined on the entity.
YAML (sigil-free):
# metaobjects/meta.blog.yaml
metadata.root:
package: acme::blog
children:
- object.entity:
name: Author
children:
- source.rdb: { table: authors }
- field.long: { name: id, filterable: true }
- field.string: { name: name, required: true, maxLength: 200, filterable: true, sortable: true }
- field.string: { name: bio, maxLength: 2000 }
- identity.primary: { fields: id, generation: increment }
- layout.dataGrid:
name: default
columns: [id, name, bio]
defaultSortField: name
defaultSortOrder: asc
pageSize: 25Canonical JSON (on-disk):
{ "layout.dataGrid": {
"name": "default",
"@columns": ["id", "name", "bio"],
"@defaultSortField": "name",
"@defaultSortOrder": "asc",
"@pageSize": 25
}}What tanstackGrid() emits per layout.dataGrid (both in
<Entity>.columns.tsx, both named from the entity + the grid's capitalized
name — authorDefaultColumns / authorDefaultGrid for "name": "default"
on Author):
<entity><Grid>Columns— array of TanStackColumnDef<T>, one per column key, each carryingmeta.view(resolved from the field'sview.*child) for the renderer registry. Omit@columnsand every field on the entity becomes a column.<entity><Grid>Grid— the config const carrying{ name, pageSize, defaultSort?, filterable }— shape declared asGridConfigin@metaobjectsdev/runtime-web.
tanstackGridHook() adds <Entity>.grid.ts with one
use<Entity><Grid>Grid() per declared grid.
field.currency is end-to-end:
- Storage: integer minor units (cents for USD, yen for JPY). DB column
is
bigint. Float arithmetic is forbidden. - Wire format: integer minor units on both request and response JSON.
- Browser formatting:
formatCurrency(cents, currency, locale)from@metaobjectsdev/runtime-webresolves the minor-unit factor viaIntl.NumberFormat.resolvedOptions().minimumFractionDigitsand emits the locale-formatted string. Server never formats. - Form input:
<CurrencyInput>from@metaobjectsdev/reactis a controlled bidirectional input — focus strips symbol + grouping for editing, blur re-formats and emits cents toonChange. Float drift is bounded by a singleMath.roundinparseCurrency.
Metadata:
{ "field.currency": {
"name": "priceCents",
"@currency": "USD",
"@required": true,
"children": [ { "view.currency": { "@locale": "en-US" } } ]
}}Consumer:
import { formatCurrency } from "@metaobjectsdev/runtime-web";
import { CurrencyInput } from "@metaobjectsdev/react";
formatCurrency(1599, "USD", "en-US"); // "$15.99"
formatCurrency(1599, "JPY", "ja-JP"); // "¥1,599"
<CurrencyInput value={1599} onChange={(c) => setCents(c)} currency="USD" locale="en-US" />See features/field-types.md for the
metadata-side reference (subtype, attrs, per-port mapping).
Generated useEntities hooks serialize a typed filter object to a
bracketed qs URL via buildFilterQs. Server-side route handlers
(TS: parseFilterParams in @metaobjectsdev/runtime-ts/drizzle-fastify)
parse the qs against a generated <Entity>FilterAllowlist.
URL grammar:
?filter[<field>][<op>]=<value>&sort=<field>:asc|desc&limit=<N>&offset=<N>
Nine operators, gated by field subtype:
| Operator | Strings | Numbers / Dates | Booleans |
|---|---|---|---|
eq, ne, isNull |
yes | yes | yes (eq + isNull) |
in, like |
yes | in only |
– |
gt, gte, lt, lte |
– | yes | – |
A bare value is sugar for eq. withCount=1 opts into the
{ rows, total } list-response envelope.
Client builder:
import { buildFilterQs } from "@metaobjectsdev/runtime-web";
buildFilterQs({
name: { like: "Asimov%" },
sort: "name:asc",
limit: 25,
offset: 0,
withCount: 1,
});
// "filter[name][like]=Asimov%25&sort=name:asc&limit=25&offset=0&withCount=1"Server-side enforcement. Every request is validated against the
generated <Entity>FilterAllowlist + <Entity>SortAllowlist. Unknown
field, disallowed operator, or invalid value → HTTP 400 with a structured
error code. Mark fields @filterable: true (and optionally
@sortable: true) to opt them in. See
features/api-contract.md for the
cross-port grammar definition.
When entities, routes, hooks, and forms need to land in different
packages (model → database package, routes → API app, hooks/forms/grids →
web app), use the targets registry. Each target is { outDir, importBase?, outputLayout?, dbImport? }.
// metaobjects.config.ts (multi-target)
import { defineConfig } from "@metaobjectsdev/cli";
// Owned generators scaffolded by `meta init` (ADR-0034 scaffold-and-own).
import { entityFile } from "./codegen/generators/entity";
import { queriesFile } from "./codegen/generators/queries";
import { routesFile } from "./codegen/generators/routes";
import { barrel } from "./codegen/generators/barrel";
import { formFile } from "@metaobjectsdev/codegen-ts-react";
import { tanstackQuery, tanstackGrid, tanstackGridHook } from "@metaobjectsdev/codegen-ts-tanstack";
export default defineConfig({
outDir: "packages/database/src/generated", // implicit "default" (entity module)
apiPrefix: "/api",
targets: {
web: { outDir: "apps/web/src/generated" },
api: { outDir: "apps/api/src/generated" },
},
// default target needs importBase so cross-target imports resolve:
entityModuleImportBase: "@acme/database/generated",
generators: [
entityFile(), // default target (database package)
queriesFile(), // default target
routesFile({ target: "api" }), // API app
formFile({ target: "web" }), // web app
tanstackQuery({ target: "web" }), // web app
tanstackGrid({ target: "web" }), // web app
tanstackGridHook({ target: "web" }), // web app (sibling of .columns)
barrel(),
],
});Cross-target references to the entity module are emitted as extension-less
importBase package paths
(@acme/database/generated/acme/blog/Author); same-target references stay
relative. With no targets, output is byte-identical to a
single-outDir project.
Full config reference: @metaobjectsdev/cli README, "Multiple output
targets".
<EntityGrid> routes cell rendering through CellRendererProvider, keyed
by the column's meta.view (a string set by codegen from the field's
view.* child). Defaults are text / textarea / number / date /
datetime / boolean / currency / dropdown / password. Override
per-key without touching generated code:
import { CellRendererProvider, EntityGrid } from "@metaobjectsdev/tanstack";
<CellRendererProvider value={{
// app-specific link style for emails
text: (ctx) => <a href={`mailto:${ctx.getValue()}`}>{String(ctx.getValue() ?? "")}</a>,
// tenant-specific currency locale
currency: (ctx) => formatCurrency(ctx.getValue() as number, "EUR", "fr-FR"),
}}>
<EntityGrid {...gridProps} />
</CellRendererProvider>Per-column cell always wins; the provider only fills in when a column
has no cell set.
Mark an entity with @emitTanstack: false and tanstackQuery() +
tanstackGrid() will skip it. The entity-file + query-file +
route-file generators still run unless they have their own opt-out.
{ "object.entity": {
"name": "InternalAudit",
"@emitTanstack": false,
"children": [ ... ]
}}The TS client is universal: it consumes any backend that implements
the URL grammar + wire format in
features/api-contract.md. All five ports
(TS, Java, Kotlin, C#, Python) ship route codegen today, including the
filter/sort querystring — the consumer does not need to hand-write a
controller on any port. See
features/api-contract.md for the
per-port route-codegen status table (hand-written examples are also there,
for the rare case where you need a shape the generator doesn't cover).
NOT PUBLISHED — source-only by decision (ADR-0048). The two
@metaobjectsdev/angular*packages build in-repo on their own0.6.xline, and neither has ever been released to npm —npm i @metaobjectsdev/angularreturns a 404. This is a deliberate position, not a pending release: the tier is below the published tier's bar — the runtime grid lacks the TanStack tier's sorting/pagination and working cell-renderer dispatch, form codegen predates the view-kind dispatch (0.18.0) and image (0.19.0) feature lines, and the runtime behavioral suite cannot execute under the repo's Bun toolchain (Angular's standard decorators need the Angular linker). The ADR carries the full promotion checklist; until it is met, everything documented below is accurate about the code and reachable by building fromclient/web/packages/angular/+server/typescript/packages/codegen-ts-angular/and consuming them as local workspace packages, not by installing from the registry. The React + TanStack tier is the published browser client.
Angular 18 ships as a second universal browser-side client tier alongside
the React + TanStack pair. Same architecture — one runtime package
(@metaobjectsdev/angular) plus one codegen package
(@metaobjectsdev/codegen-ts-angular). Same universality: any backend
implementing the REST contract serves it.
| Package | Purpose | Key exports |
|---|---|---|
@metaobjectsdev/angular |
Angular 18 runtime — standalone components, signals, peer-deps on @angular/core, @angular/common, @angular/forms, @tanstack/angular-table |
EntityFetcherToken, provideEntityFetcher, <mo-currency-input> (CurrencyInputComponent), <mo-entity-grid> (EntityGridComponent), CellRendererRegistry, type EntityGridColumn; re-exports EntityFetcher, GridConfig, formatCurrency, parseCurrency, buildFilterQs from runtime-web |
@metaobjectsdev/codegen-ts-angular |
Angular codegen — emits standalone components + signal-based services | angularServiceFile(), angularFormFile(), angularGridFile(), barrel() |
Per-entity opt-out: mark an entity with @emitAngular: false and all three
Angular outputs are skipped.
Angular DI replaces React context as the fetcher transport. The same
EntityFetcher shape (<T>(path, init?) => Promise<T>) flows through the
EntityFetcherToken injection token.
// app.config.ts
import { ApplicationConfig } from "@angular/core";
import { provideZoneChangeDetection } from "@angular/core";
import { provideRouter } from "@angular/router";
import { provideEntityFetcher } from "@metaobjectsdev/angular";
import { routes } from "./app.routes";
const fetcher = async <T,>(path: string, init?: RequestInit): Promise<T> => {
const res = await fetch(path, {
...init,
credentials: "include",
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
});
if (!res.ok) throw new Error(`HTTP ${res.status} on ${path}`);
return res.status === 204 ? (undefined as T) : ((await res.json()) as T);
};
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideEntityFetcher(fetcher),
],
};Metadata (same Author entity as the React examples above):
// metaobjects/meta.blog.json
{ "metadata.root": {
"package": "acme::blog",
"children": [
{ "object.entity": {
"name": "Author",
"children": [
{ "source.rdb": { "@table": "authors" } },
{ "field.long": { "name": "id" } },
{ "field.string": { "name": "name", "@required": true, "@maxLength": 200 } },
{ "field.string": { "name": "bio", "@maxLength": 2000 } },
{ "field.boolean":{ "name": "active" } },
{ "identity.primary": { "@fields": "id", "@generation": "increment" } },
{ "layout.dataGrid": {
"name": "default",
"@columns": ["name", "bio", "active"],
"@defaultSortField": "name",
"@defaultSortOrder": "asc",
"@pageSize": 25
}}
]
}}
]
}}metaobjects.config.ts adding the Angular generators:
import { defineConfig } from "@metaobjectsdev/cli";
// Owned generators scaffolded by `meta init` (ADR-0034 scaffold-and-own).
import { entityFile } from "./codegen/generators/entity";
import { queriesFile } from "./codegen/generators/queries";
import { routesFile } from "./codegen/generators/routes";
import { barrel } from "./codegen/generators/barrel";
import {
angularServiceFile,
angularFormFile,
angularGridFile,
barrel as angularBarrel,
} from "@metaobjectsdev/codegen-ts-angular";
export default defineConfig({
outDir: "packages/database/src/generated",
apiPrefix: "/api",
targets: {
angular: { outDir: "apps/angular-web/src/app/generated" },
},
entityModuleImportBase: "@acme/database/generated",
generators: [
entityFile(),
queriesFile(),
routesFile(),
angularServiceFile({ target: "angular" }),
angularFormFile({ target: "angular" }),
angularGridFile({ target: "angular" }),
angularBarrel({ target: "angular" }),
barrel(),
],
});Consumer's Angular component composing the generated service + grid:
// app.component.ts
import { Component, inject, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { AuthorGridComponent, AuthorService } from "./generated/acme/blog";
@Component({
selector: "app-root",
standalone: true,
imports: [CommonModule, AuthorGridComponent],
template: `<author-grid #grid />`,
})
export class AppComponent implements OnInit {
private readonly authors = inject(AuthorService);
async ngOnInit() {
const rows = await this.authors.list({ limit: 25, sort: "name:asc" });
// hand the rows to the grid component (in real code, signal binding or store)
}
}docs/recipes/csharp-angular18.md— end-to-end recipe for wiring an ASP.NET Minimal API backend (C# 12 / .NET 8) to an Angular 18 client built with these packages; covers CORS, dev-server ports, and base-URL configuration.
docs/features/api-contract.md— the cross-port REST contract this client speaksdocs/features/field-types.md— field subtype reference (currency, enum, etc.) shared across portsdocs/ports/typescript.md— server-side TypeScript port (entities, queries, routes, OMDB-style runtime)CLAUDE.md— sections "TS package layout", "Codegen architecture", "Filter syntax + sort (Project D)", "Source-aware entities + projections (Project E)", "Currency (Project F)", and "TanStack codegen + metadata-driven grids (Project B)" for the authoritative design notes