@mackehansson/schemaform

Persistence & Versioning

Store Form Definitions in your own system and migrate them through Schemaform format versions.

Schemaform is storage-agnostic. The consuming application decides where Form Definitions live: Postgres, S3, a CMS, a Git-backed config repository, or any other backend.

The persisted unit is one versioned envelope:

import type { FormDefinition } from "@mackehansson/schemaform";

const definition = {
  formatVersion: 1,
  schema: {
    type: "object",
    properties: {
      email: { type: "string", title: "Email", format: "email" },
    },
    required: ["email"],
  },
  uiSchema: {
    pages: [
      {
        type: "Page",
        title: "Contact",
        children: [{ type: "Control", scope: "#/properties/email" }],
      },
    ],
  },
  rules: [],
} satisfies FormDefinition;

The envelope keeps the three layers together:

KeyOwns
formatVersionThe Form Definition format version
schemaJSON Schema data and validation constraints
uiSchemaPresentation: Pages, layout nodes, Controls, widgets, navigation, and reveal behavior
rulesBehavior evaluated against Working State before validation

Store the whole envelope atomically when you can. Splitting the documents across storage records makes it easier for Rules to point at removed Controls or for a UI Schema to drift away from the JSON Schema it presents.

Loading persisted definitions

Run persisted JSON through migrate before rendering or editing it. migrate upgrades older supported envelopes to the current format and returns a typed FormDefinition.

import { migrate, type FormDefinition } from "@mackehansson/schemaform";

async function loadDefinition(id: string): Promise<FormDefinition> {
  const response = await fetch(`/api/forms/${id}`);
  const raw = await response.json();

  return migrate(raw);
}

For v1, formatVersion: 1 is the initial format. Future Core releases will add migration steps keyed by the source version.

Saving definitions

Schemaform does not write to your database. The Form Builder edits a Form Definition in memory; your app owns the save action.

import { useFormBuilder } from "@mackehansson/schemaform";
import type { FormDefinition } from "@mackehansson/schemaform";

export function FormDesigner({
  initialDefinition,
}: {
  initialDefinition: FormDefinition;
}) {
  const builder = useFormBuilder({ initialDefinition });

  async function save() {
    await fetch("/api/forms/contact", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(builder.definition),
    });
  }

  return <button onClick={save}>Save</button>;
}

Save drafts, revisions, publishing state, audit trails, permissions, and deployment workflows in your product model. Schemaform only defines the portable Form Definition shape.

Version errors

migrate throws when the input is not a JSON object, is missing the numeric formatVersion, references a future version, or has no registered migration path.

import { migrate } from "@mackehansson/schemaform";

try {
  const definition = migrate(rawDefinitionFromStorage);
  render(definition);
} catch (error) {
  reportBrokenDefinition(error);
}

Treat that as an operational error in your app: show an admin-facing repair message, block rendering the broken form, and keep the raw stored document available for recovery.

Reusing schemas

The layers remain distinct keys inside the envelope. If your product needs to reuse a JSON Schema across multiple presentations, compose separate envelopes that share the same schema value but carry different uiSchema and rules.

At render time, always pass the complete Form Definition envelope to the Form Renderer.

On this page