# TypeScript client (web) 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/`). ## What this covers 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`](../features/api-contract.md). ## Architecture: two-package pattern 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"](#angular-18) below. The two-package split is the shape a first-party integration takes when there is one — it is not a commitment to add more. Reaching another framework is an ownership move, not a roadmap item: eject the generator and retarget its emit (FR-040). ## Browser runtime packages | 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`, ``, types `EntityMeta`, `EntityFieldMeta`, `BoundInputProps` | | `@metaobjectsdev/tanstack` | TanStack runtime (peer-deps on `@tanstack/react-query`, `@tanstack/react-table`). | ``, `useEntityFetcher`, ``, ``, `defaultCellRenderers` | ## Codegen packages | Package | Generators | What it emits | |---|---|---| | `@metaobjectsdev/codegen-ts-react` | `formFile()` | `.form.tsx` — a per-entity React form using `useEntityForm` + `` | | `@metaobjectsdev/codegen-ts-tanstack` | `tanstackQuery()`, `tanstackGrid()`, `tanstackGridHook()` | `.hooks.ts` (5 React Query hooks) for every entity; `.columns.tsx` (TanStack Table column defs) and `.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). ## `metaobjects.config.ts` wiring 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): ```ts // metaobjects.config.ts import { defineConfig } from "@metaobjectsdev/cli"; // Owned generators scaffolded by `meta init` (ADR-0034 scaffold-and-own). import { entityFile } from "./codegen/generators/entity.js"; import { queriesFile } from "./codegen/generators/queries.js"; import { routesFile } from "./codegen/generators/routes.js"; import { barrel } from "./codegen/generators/barrel.js"; 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). ## The `` contract 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. ```ts // from @metaobjectsdev/runtime-web export type EntityFetcher = (path: string, init?: RequestInit) => Promise; ``` ```tsx // In the consumer's app root: import { EntityFetcherProvider } from "@metaobjectsdev/tanstack"; const fetcher = async (path: string, init?: RequestInit): Promise => { 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 ( {/* generated hooks now have a fetcher */} ); } ``` The URL grammar this fetcher must speak is defined in [`features/api-contract.md`](../features/api-contract.md) — `GET /api/author?...`, `POST /api/author`, etc. > **These generators are yours.** `formFile()`, `tanstackQuery()`, `tanstackGrid()` and > `tanstackGridHook()` are ordinary generators with reference templates you can take ownership of: > `meta eject form` (or `hooks` / `grid` / `grid-hook`) copies one into `codegen/generators/`. > > If your framework compiles server and client from one tree and resolves each half under different > export conditions — React Server Components, Angular universal, Qwik — an emitted client artifact > may need a marker directive. That is a one-line change in the generator you own: inside its > existing `if (!ctx.renderContext) throw …` guard (every reference template has one — `ctx.renderContext` > is optional on the raw context), change only the `content:` line to > `content: '"use client";\n' + renderFormFile(entity, ctx.renderContext)`. A resolution error in > that situation often names a package that IS installed; read it as a boundary problem, not a > missing dependency. Full procedure: the `metaobjects-codegen` skill, "Your framework isn't the > default". ## Generated React forms `formFile()` emits a `.form.tsx` per entity. The form imports `useEntityForm` (React Hook Form bound to a generated Zod insert schema) and exposes a `.input.` accessor for each field. Spread it onto an `` element — every metadata-derived attribute (placeholder, type, aria-label, RHF rules) rides along automatically. Metadata: ```jsonc // 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, "children": [ { "view.text": {} } ] } }, { "field.string": { "name": "bio", "@maxLength": 2000, "children": [ { "view.textarea": {} } ] } }, { "identity.primary": { "@fields": "id", "@generation": "increment" } } ] }} ] }} ``` Generated `Author.form.tsx` (consumer's view): ```tsx // 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 (