The problem with one unit form
A residential unit cares about bedrooms and bathrooms. A commercial unit cares about square footage and zoning class. A storage unit cares about climate control and whether it's drive-up accessible. Any single hardcoded form either grows an ever-longer list of optional fields that mostly don't apply, or PropertyOS needs a new form component — and a new migration, and a new PR — every time HQLead wants to onboard a property type nobody anticipated.
The alternative: don't hardcode the fields. Let HQLead define them, per property type, as data. The unit form becomes a renderer for whatever schema comes back from the API, not a fixed template with blanks to fill in.
What "the schema is data" actually means
unit_attribute_definitions is a table, not a set of columns:
property_type_id, attribute_key, label, data_type
(text / number / boolean / select), is_required, options
(for select). HQLead adds a row, and every unit under that property
type immediately has a new field — no deploy, no migration, no code
change. A unit's actual values live in one JSONB column,
units.attributes, keyed by attribute_key.
The frontend renders that schema directly:
export default function DynamicAttributeField({ definition }) {
if (definition.data_type === "boolean") { /* ... */ }
if (definition.data_type === "select") { /* ... */ }
if (definition.data_type === "number") { /* ... */ }
return <Input name={`attr_${definition.attribute_key}`} label={definition.label} type="text" />;
}
One component, switching on data_type, mapped over whatever
definitions the API returns for the unit's property type. Add a
select field called "HVAC zone" to Commercial units in the database,
and it appears on every commercial unit's create/edit form immediately
— nothing on the frontend had to know that field would ever exist.
The part that has to not be dynamic: validation
A flexible form is a UI convenience. It says nothing about what
actually reaches the database. units.attributes is JSONB — Postgres
will happily store {"bedrooms": "yes", "extra_field_nobody_defined": 42}
without complaint, schema or no schema. So the schema has to be
enforced again, for real, on the way in:
for (const def of definitions) {
const value = attributes[def.attribute_key];
const isEmpty = value === undefined || value === null || value === "";
if (def.is_required && isEmpty) {
throw new AppError(`"${def.label}" is required`, 400);
}
if (isEmpty) continue;
if (def.data_type === "number" && typeof value !== "number") {
throw new AppError(`"${def.label}" must be a number`, 400);
}
// ...boolean, text, select follow the same shape
}
// Reject keys the schema doesn't define — keeps the payload honest
// to the property type's schema rather than silently accepting
// arbitrary JSON alongside it.
const allowedKeys = new Set(definitions.map((d) => d.attribute_key));
for (const key of Object.keys(attributes)) {
if (!allowedKeys.has(key)) {
throw new AppError(`Unknown attribute "${key}" for this property type`, 400);
}
}
Two passes, deliberately: the first checks that every required field
made it and every present field has the right type; the second
rejects any key that isn't in the schema at all. Without that second
pass, a client could attach arbitrary extra JSON to attributes and
Postgres would store it forever — dynamic schema becomes "no schema"
the moment nothing checks the edges of it. This is a plain function
over two plain arrays, not a runtime-compiled Zod schema or a
JSON-schema library — the validation rules are simple enough that the
extra machinery would cost more to read than it would save to write.
Where "dynamic" stops on purpose
Two things are deliberately not data-driven, because letting them be would create more risk than flexibility is worth:
-
data_typeis a fixed enum —text/number/boolean/select, checked withALLOWED_DATA_TYPES.has(...)at definition creation. HQLead can define infinite fields, but not infinite field kinds. Adding a new kind (a date picker, a file upload) is still a real code change, on both ends, that touchesDynamicAttributeField.jsxandvalidateUnitAttributes.jsdeliberately together. That's the honest boundary: the shape of the schema system is fixed; only its content is dynamic. -
Who can define a schema is not dynamic at all. Only HQLead can create an attribute definition — a landlord admin can fill in values for whatever fields exist, but can't invent new ones for their property type:
if (!actor || actor.type !== "hqlead") { throw new AppError( "Only HQLead can define unit attributes for a property type", 403 ); }If that boundary didn't exist, "dynamic schema" would really mean "every landlord admin has their own private, uncoordinated dialect of what a unit is" — which defeats the actual point, which is a platform operator being able to standardize what a commercial unit looks like across every landlord on PropertyOS, not each landlord freelancing their own.
Where this earns its complexity, and where it wouldn't
This pattern is worth its weight for property types HQLead genuinely
doesn't know the shape of in advance, or that vary a lot between
landlords — which was exactly the situation once storage units and
commercial units showed up alongside residential ones. It would not
have been worth it for a single, stable, universally-shared field set:
that's what units.rent_amount and units.status already are —
plain, typed, indexed columns, not entries in attributes. The
dynamic path is reserved for fields that genuinely differ by property
type; anything every unit has in common stays a real column, with a
real type the database enforces on its own.
What it doesn't handle yet
- No schema versioning. If HQLead edits a definition's
data_typeafter units already have values under the old type, nothing reconciles the existing data — anumberfield flipped toselectwould leave old numeric values that no longer validate against the new option list, discovered only the next time that unit is saved. optionsfor aselectfield is unvalidated JSON on the way in. The attribute definition itself isn't schema-checked as rigorously as the values filled into it — HQLead is a trusted actor here, so this is a smaller risk than the landlord-admin-facing side, but it's an asymmetry worth naming rather than assuming away.
Live link coming soon