Your first resource
By the end of this walkthrough you will have a resource — Manufacturer — that
lists, creates, edits, and validates against a schema. You need a checked-out
OnERP that runs (pnpm dev) and enough React to read JSX.
OnERP is metadata-driven. You declare a business object — the schema — and a
resource: where that schema lives in the UI, and which components render it.
The framework reads both, generates the routes, fetches the data, and renders
the form and the list. There is no glue in between, and no Manufacturer
interface to keep in step — every type is computed from the declaration.
The walkthrough adds the object to domains/master, an existing domain package;
a brand-new one needs the four extra steps at the foot of the page.
Step 1 — Define the schema
Section titled “Step 1 — Define the schema”One folder per business object, and the schema file inside it is always
schema.ts —
how a domain package is laid out.
import type { BusinessObject } from "@onerp/meta/records";import { defineBusinessObject, field } from "@onerp/meta";import { currencyCatalog } from "@onerp/catalogs";
export const ManufacturerSchema = defineBusinessObject( "master/Manufacturer", { name: field.string({ label: "Name", required: true, searchable: true }), code: field.string({ label: "Code", searchable: true }), currency: field.codeList(currencyCatalog, { label: "Currency" }), minimumOrderValue: field.amount({ label: "Minimum Order Value" }), notes: field.text({ label: "Notes" }), }, { representation: "name" });
export type Manufacturer = BusinessObject<typeof ManufacturerSchema>;Field kinds. Money is field.amount(), not a raw decimal — along with
field.quantity() and field.rate() it is a semantic builder that pins the
right precision and scale. See Value types;
currency is a code list.
required: true is what removes | null from a field’s type, so declare it
only for a value an active record cannot lack. searchable: true puts a
field behind the list’s search box; declare none and no search box appears at
all. representation names the field a reference to this record
renders as.
No archivable, and no status field. archivable defaults to true,
giving the standard master-data lifecycle: draft → active ↔ archived; set it to
false only for a record that must never be retired, such as a posted document
(one flag: archivable). Status
is system metadata: defineBusinessObject throws if it finds a status field.
And the instance type on the last line is not decoration — every schema publishes
one beside itself, and consumers import that name.
Step 2 — List it on the plugin
Section titled “Step 2 — List it on the plugin”@Plugin({ name: "master", businessObjects: [ // … ManufacturerSchema, ], // …})export class MasterPlugin {}A schema that no plugin lists is never composed — it compiles, and the business object simply does not exist (what the plugin declares).
Step 3 — Create the table
Section titled “Step 3 — Create the table”Adding a business object does not create its table. Tables are synced from meta by a provisioner the HTTP server deliberately never calls — schema state is a deploy step, not a boot step.
pnpm build # the CLI runs against the compiled domain packagespnpm cli migrate # sync business object tables from meta, for every tenantmigrate is idempotent. Run it again after any schema change.
Step 4 — Write a form
Section titled “Step 4 — Write a form”A form body calls useForm<S>() to get a schema-bound factory, then declares
which input binds to which field. The type parameter ties every name to the
schema, so a typo or a wrong input kind is a compile error.
import type { ManufacturerSchema } from "@onerp/master/manufacturer/schema";import { FormGrid, useForm } from "@onerp/react/forms";
export const ManufacturerForm = () => { const f = useForm<typeof ManufacturerSchema>(); return ( <FormGrid> <f.Text autoComplete="off" name="name" /> <f.Text autoComplete="off" name="code" /> <f.CodeList name="currency" /> <f.Decimal name="minimumOrderValue" /> <f.Text className="md:col-span-2" multiline name="notes" rows={3} /> </FormGrid> );};The import is import type — a domain package is server code, and only its types
may cross into the browser. Note what is not there: no labels. Each input reads
its label from the field definition.
Step 5 — Write a list
Section titled “Step 5 — Write a list”import type { ManufacturerSchema } from "@onerp/master/manufacturer/schema";import { DataTable } from "@onerp/react/lists";
export const ManufacturerList = () => ( <DataTable<typeof ManufacturerSchema> columns={["name", "code", "currency", "minimumOrderValue"]} />);A bare string is a field path, checked against the schema like the form’s name
props; header text, alignment and click-to-sort resolve from the field
definition. You declare the columns; the framework does the rest.
Step 6 — Register the resource
Section titled “Step 6 — Register the resource”import type { ManufacturerSchema } from "@onerp/master/manufacturer/schema";import { defineResource } from "@onerp/react/resource";import { ManufacturerForm } from "./manufacturer-form";import { ManufacturerList } from "./manufacturer-list";
export const manufacturerResource = defineResource<typeof ManufacturerSchema>({ ref: "master/Manufacturer", form: ManufacturerForm, list: ManufacturerList,});ref is the binding between the UI and the schema. Passing the schema as the
type parameter checks that string against the schema’s own name, so a drifted
ref fails to compile. Add manufacturerResource to the resources array handed
to <OnerpApp> in apps/web/src/main.tsx, and three routes exist:
/master/Manufacturer is the list, /master/Manufacturer/create the empty form,
/master/Manufacturer/:id the loaded one.
The subpath import in steps 4–6 resolves only because @onerp/master is a
dependency of apps/web — in devDependencies, since nothing but types crosses.
Step 7 — Add it to the navigation
Section titled “Step 7 — Add it to the navigation”Registering a resource creates routes, not menu items. The sidebar is the
application’s own chrome, written by hand in apps/web/src/shell/app-sidebar.tsx:
const masterDataItemDefs: NavItemDef[] = [ // … { to: "/master/Manufacturer", icon: Factory, resource: "master/Manufacturer" },];Naming the resource rather than a label is what makes the item read
Manufacturers in every locale — the business object’s label, derived from its name
here or declared as label: { singular, plural }, lives on the schema with its
translations.
Step 8 — See it run
Section titled “Step 8 — See it run”Start the stack with pnpm dev and open
http://localhost:5173/master/Manufacturer. The list renders your four columns,
sorted and paginated, with a Create button, a search box (because two fields
declared searchable), a filter bar, and a Show archived toggle. Creating a
manufacturer opens the form and refuses to save until Name is filled. For a
fully-featured resource, read apps/web/src/resources/products/ alongside
domains/master/src/product/schema.ts.
Starting a new domain
Section titled “Starting a new domain”A brand-new domain package adds five steps, all covered by how a domain package is laid out:
domains/<name>/package.json— copy a neighbour’s; theexportsmap needs.and the./*wildcard the subpath imports use.src/plugin.ts— the NestJS module,@Plugin({ name, businessObjects, extensions, providers, migrations, seed }), re-exported fromsrc/index.ts.apps/api/src/app.module.ts— add the plugin class toimports, or it does not boot.apps/web/package.json— add the package todevDependenciesso theimport typeresolves. Once the plugin is inapp.module.ts,apps/api/src/meta-completeness.test.tscovers it: a def missing a German translation or a description fails the test run (see Translations).
Where to go next
Section titled “Where to go next”The rest of the runtime surface — Forms, Lists, Custom screens — and the framework underneath it, in Foundations.