Using https://dev.to/yne/a-one-liner-for-createelement-with-attributes-and-children-55nh
The following snippet convert any JSON Schema to DOM elements.

const el = (tag, props = {}, ch = [], attrs = {}) => ch.reduce((e, c) => (e.appendChild(c), e), Object.entries(attrs).reduce((e, [k, v]) => (e.setAttribute(k, v), e), Object.assign(document.createElement(tag), Object.fromEntries(Object.entries(props).filter(([key, value]) => value !== undefined)))));

function schema2dom(item, name = "", parent = {}) {
    let ul, total; // this total  track if we stay in minItems/maxItems
    if (item.type == "array") return [ul = el("ul", {}, [el("li", {}, [el("button", {
        type: "button", innerText: "Add", onclick: () => ul.insertAdjacentElement("beforeend", el("li", { ariaRowIndex: total.ariaRowIndex = ++total.value }, [
            el("button", { type: "button", innerText: "Remove", onclick() { total.ariaRowIndex = --total.value; this.parentElement.remove() } }),
            ...schema2dom(item.items, name, parent.required?.includes(name))
        ]))
    }), total = el("input", { type: "number", ariaRowIndex: 0, value: 0, min: item.minItems, max: item.maxItems, oninput() { this.value = this.ariaRowIndex } })])])];
    if (item.type == "object") return [el("dl", {  }, Object.entries(item.properties).map(([name, v]) => [
        el("dt", { innerText: v.title || name, title: v.description || '' }),
        el("dd", {}, schema2dom(v, name, item)),// "additionalProperties", propertyNames+pattern, minProperties maxProperties
    ]).flat())];
    const common = { name, title:item.description||parent.description, required: parent.required?.includes(name), minLength: item.minLength, maxLength: item.maxLength, pattern: item.pattern, min: item.minimum, max: item.maximum };
    if (item.enum) return [el("select", common, item.enum.map((v,i) => el("option", { innerText: v === Object(v) ? JSON.stringify(v) : v, value: JSON.stringify(v), title:(item.markdownEnumDescriptions||item.enumDescriptions||[])[i]})))];
    const datalist = [el("datalist", { id: `list_${Math.random()}` }, item.examples?.map(ex => el("option", { innerText: ex, title: JSON.stringify(ex) })))];
    if (item.type == "boolean") return [el("input", { type: "checkbox", checked: item.default, ...common }), ...datalist];
    if (item.type == "integer") return [el("input", { type: "number", value: item.default, step: item.multipleOf || 1, ...common }), ...datalist];
    if (item.type == "number") return [el("input", { type: "number", value: item.default, step: item.multipleOf, ...common }), ...datalist];
    if (item.type == "string") return [el("input", { type: "text", value: item.default, ...common }, [], { list: datalist?.[0].id }), ...datalist];
    return []; // item.type == "null"
}

For example:

schema2dom({type:"object",properties:{n:{type:"number"},s:{type:"string"}}})

Will return 2 fields: a plus a

Limitation

  • examples can't be array or object they will be toString() and put as value of
  • Object are non-extensible (see additionalProperties and associated propertyNames,pattern,minProperties,maxProperties.

Serialization

Simple Schema generated

DataForm()
const obj = Object.fromEntries((new FormData(myForm)).entries())

However, nested list/objects require a specific serialization:

function serialise(el) {
    if (el.tagName == 'DL') return Object.fromEntries([...el.children].reduce((acc, _, i, ch) => i % 2 ? acc : [...acc, [ch[i].innerText, serialise(ch[i + 1].children[0])]], []))
    if (el.tagName == 'UL') return [...el.children].slice(1).map(li => serialise(li.children[1]))
    if (el.tagName == 'SELECT') return JSON.parse(el.value);
    if (el.type == 'checkbox') return el.checked;
    if (el.type == 'number') return el.valueAsNumber;
    if (el.type == 'text') return el.value;
    return null;
}

Style

No style are required, but a grid display could help.

dl,ul>li {
    display: grid;
    grid-template-columns: 1fr 5fr;
}
dd { display: contents; }
dl, ul {
    padding: 0;
    margin: 0;
}