Vivek Jalondhara. All writing

Frontend

Schema-Driven UIs: Building Page and Form Builders

By Vivek Jalondhara · 6 min read
Published Jan 2026 · Updated

When users need to build their own layouts, describe the UI as data and render it dynamically.

Page and form builders look like magic but rest on one idea: the UI is data. A JSON schema describes components, layout, and config; a renderer walks that tree and produces React. Users compose schemas via drag-and-drop without touching code.

The discipline is in the component registry. Each block declares its props, defaults, and validation, so the renderer and the editor stay in sync. Versioning the schema is non-negotiable — saved layouts must survive future component changes.

Done right, this collapses development dependency: non-technical users ship pages and forms themselves, and engineering focuses on the platform instead of one-off screens.

Define what a customer is allowed to change

A CRM page builder starts with a product question: which decisions should the customer control? Moving fields, adding a heading, and choosing from approved layouts are different from running arbitrary JavaScript. My CRM work includes configurable pages and forms. The walkthrough here uses a small illustrative builder to explain the design decisions without exposing private product schemas.

Start with a constrained page: a heading, a group of record fields, and an action that the application already supports. Let configuration choose among those capabilities. Do not add a general scripting language to solve a layout problem. A limited vocabulary is easier to validate, document, migrate, and support when a customer reports that a saved page no longer renders.

Separate saved configuration from the record being edited

A page definition should describe which field to render, not copy the current customer's value into the layout. Store a field reference in the schema, fetch the authorized record separately, and combine them during rendering. This separation allows the same layout to display several records without accidentally saving personal data as part of a reusable template.

Give each block a stable ID and place a version on the saved page. A display label is not a reliable identity because it can change or be translated. Array position is not stable when blocks move. Keep editor-only state, such as the selected block and an open settings panel, out of the persisted definition unless it is deliberately part of the product's saved behavior.

Use one registry for supported components

The registry connects a block type to its renderer, supported properties, defaults, and editor controls. The editor offers blocks from this registry; the page renderer resolves the same names. A missing type should produce an understandable unsupported-block message rather than crashing the entire page. That fallback also helps when an old client encounters a newer saved definition.

For a text field, the configuration might include a field ID, label override, and required flag. It should not include an executable event handler. Resolve data access through application-owned services, and allow only supported destinations for links or actions. The boundary is simple: saved data selects a capability; it does not grant new authority to execute code or retrieve records.

Validate at the storage boundary

A TypeScript union helps authors work with known block types, but it does not validate a JSON request at runtime. Check the version, block types, property types, allowed values, and referenced field IDs when receiving a saved page. Bound nesting depth and block count so malformed input cannot create a huge recursive render tree. Validate again when reading historical data that may predate today's checks.

Return errors with locations the editor can show, such as the block ID and invalid property. If a referenced field was removed, preserve enough context for the user to repair the layout. Silently deleting unsupported blocks can turn a recoverable configuration problem into data loss. Keep the last accepted page available while a new draft is being validated.

Plan migrations as ordinary data changes

Suppose a field block originally stores one label string and a later version needs locale-specific labels. Existing pages do not change just because the React component changed. A migration must define how the old value becomes the new representation, including what happens if a value is missing. Keep that transformation separate from rendering so it can be inspected and tested with saved fixtures.

Decide whether migration happens on read or as a controlled batch. On-read conversion is convenient but can repeat work and leave several stored versions in circulation. A batch is easier to inventory but needs recovery from partial completion. Neither approach should discard the original definition before the converted page has been validated. A version number identifies a contract; it does not perform the migration itself.

Preserve form state when blocks move

A user reordering a page should not lose text they are typing into a field. React uses component identity and tree position to determine whether state is preserved. Use stable block IDs as keys when rendering a list; generating a new key during every render forces replacement. Changing a key can be useful when deliberately switching to a different record, but it should be an explicit reset decision.

Keep the builder's drag state separate from the values being edited in the preview. Decide how record changes affect unsaved input and show a clear save or discard choice. Test reordering, deleting, and restoring a field while it contains a value. These interactions expose state ownership problems that a screenshot of the final layout cannot reveal.

Keep permissions and accessibility in the application

Hiding a block is a presentation decision, not a security control. The API must independently enforce record and field permissions when loading or saving values. A user who cannot view a field should not receive it in the response merely because the builder promises not to render it. Configuration validation and authorization protect different boundaries and both are needed.

Test the page as a keyboard user: labels must remain associated with inputs, validation messages must be discoverable, and a custom layout must not create an unusable focus order. Start with one useful page and a small registry. Expand when customers need another supported block, using saved-page fixtures and permission tests to protect the behavior they already rely on.

Version the saved page, not just its components

A configurable CRM page needs a saved format that survives code changes. A small versioned union makes supported block types explicit and gives future migrations a starting point.

Illustrative example · TypeScript · Simplified for explanation

type Block =
  | { id: string; type: "heading"; text: string }
  | { id: string; type: "field"; fieldId: string; required: boolean };

type SavedPage = {
  version: 1;
  blocks: Block[];
};
// Validate incoming JSON at runtime before treating it as SavedPage.
How the example fits together
  1. Editor config
  2. Validate versioned schema
  3. Allowed component registry
  4. Rendered page

Conceptual flow for the example above.

Tradeoff to keep in mind

Schema-driven layouts trade unrestricted customization for predictable rendering. Validate stored data at runtime and never execute arbitrary code from a saved layout.

Related project: SmackDab CRM →

References & further reading

Related posts