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_type is a fixed enumtext / number / boolean / select, checked with ALLOWED_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 touches DynamicAttributeField.jsx and validateUnitAttributes.js deliberately 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_type after units already have values under the old type, nothing reconciles the existing data — a number field flipped to select would leave old numeric values that no longer validate against the new option list, discovered only the next time that unit is saved.
  • options for a select field 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