Texaryn is a headless runtime for JSON Schema forms. A schema adapter reads Draft 7, 2019-09 or 2020-12, a framework-neutral core owns state, validation and submission, and a React, Vue or Web Components binding renders the result.
If you've shipped react-jsonschema-form, here's what's different: array row identity lives in the runtime and follows the row's data or an itemKey you name, each field chooses whether it validates on blur, change or submit, and the same runtime renders under React, Vue or a web component. It's pre-1.0.
Why I built it
I've maintained AJSF, the Angular JSON Schema form library, since 2018. It continues dschnelldavis/angular2-json-schema-form in its own repository: the first commit, in July 2018, brought that code to Angular 6. Its major version follows the Angular major it targets, and 22.2.1 is on Angular 22.
It has 359 stars and 177 forks, and @ajsf/core had 17,630 npm downloads from 23 August to 21 September 2026.
AJSF already keeps the schema logic apart from the design system (Material, Bootstrap, PrimeNG), but all of it runs inside Angular. Texaryn grew out of that, and out of exploring what AI needs to build interfaces: describe the interface as structured data, let a runtime own state and validation, and keep rendering in a thin layer per framework.
In RJSF, Form processes the schema, manages state and renders, all in React. I wanted three separate owners: an adapter that understands JSON Schema, a runtime that owns form state, and a binding that renders a framework-neutral description of the form.
The first user I had in mind builds form-heavy React apps from JSON Schema: configuration screens, multi-step onboarding, admin panels, data entry tools. They want per-field dirty and touched state, validation timing per field, and custom components without forking a theme, in React today without being locked into React. Vue was the second binding on purpose: setup runs once and reactivity is tracked per ref, where React re-runs a component on every render, so it shows whether the binding contract quietly assumed React's model.
How it works
JSON Schema
↓ adapter (@texaryn/schema-json)
SchemaProjection
↓ compiler (@texaryn/core)
UIDocument
↓ runtime (@texaryn/core)
FormRuntime state
↓ binding (@texaryn/react, @texaryn/vue, @texaryn/web-components)
Widgets (a default set per binding; Bootstrap 5 and Material UI for React)
createJsonSchemaAdapter returns an object that implements SchemaEvaluationPort, the interface the runtime calls to project and validate data. A projection is the fields, constraints and active branches for the current data, so it's recomputed as the user edits.
The compiler turns the projection into a UIDocument: a versioned tree of typed nodes that says what the form contains, not how React, Vue or the DOM draws it. A binding receives that document rather than the schema, so conditionals, composition and dependencies are evaluated in one place, the adapter, and no binding reimplements them.
FormRuntime owns values, validation, dirty and touched state, visibility, disabled state, submission and array identity. Bindings subscribe to it instead of keeping their own copy. The core has no dependencies at all, and the UI definition is data: no generated JavaScript and no eval().
On the rendering side, a binding (@texaryn/react, @texaryn/vue, @texaryn/web-components) connects the runtime to a framework. A registry maps each UI node to a component, and a widget set such as @texaryn/react-bootstrap or @texaryn/react-mui is a ready-made registry for React.
The repository checks the neutrality claim: every example in its catalog runs through each binding and widget set, and the build fails when a documented feature has no example. The playground shows the same examples through React Default, React Bootstrap 5, React Material UI, Vue Default and Web Components Default.
The schema
All three examples render this schema.ts:
export const schema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
title: 'Profile',
properties: {
name: { type: 'string', title: 'Name', minLength: 1 },
age: { type: 'integer', title: 'Age', minimum: 18 },
},
required: ['name'],
}
required only checks that the key exists, and '' counts as a value, so minLength: 1 is what rejects an empty name.
In React
pnpm add @texaryn/core @texaryn/schema-json @texaryn/react react react-dom
@texaryn/react needs React 18 or newer. The packages are ES modules only, and the examples await the adapter at module top level, which needs a build target that supports it, such as es2022.
import { createRoot } from 'react-dom/client'
import { createJsonSchemaAdapter } from '@texaryn/schema-json'
import {
ErrorSummary,
FormProvider,
FormRoot,
createDefaultRegistry,
useForm,
} from '@texaryn/react'
import { schema } from './schema'
const adapter = await createJsonSchemaAdapter(schema)
const registry = createDefaultRegistry()
function App() {
const form = useForm(adapter, {
initialData: { name: '', age: 18 },
hints: {
'/name': { placeholder: 'Ada Lovelace', validationTrigger: 'blur' },
},
onSubmit: async (data) => console.log(data),
})
return (
<FormProvider value={form.runtime}>
<ErrorSummary />
<FormRoot registry={registry} />
<button
type="button"
disabled={
form.submission.status === 'validating' ||
form.submission.status === 'submitting'
}
onClick={() => form.dispatch({ type: 'Submit' })}
>
Submit
</button>
</FormProvider>
)
}
createRoot(document.getElementById('root')!).render(<App />)
useForm creates the runtime and subscribes to its stores, and FormProvider makes it available to ErrorSummary and FormRoot. FormRoot renders fields only, with no <form> around them, so the button dispatches the Submit command itself and Enter doesn't submit. With an empty name, onSubmit isn't called, and ErrorSummary takes focus under the heading "There is a problem" and lists Name: Value `#/name` should have a minimum length of `1`, but got `0`.
That message comes from the validator, and there's no way to rewrite it yet. The default widgets set aria-invalid and link aria-describedby through getInputProps and getErrorProps, which a custom widget can call too.
Hints go in the same options as initialData, keyed by JSON Pointer. A field hint sets widget, order, placeholder, helpText or validationTrigger ('blur', 'change' debounced 300 ms by default, or 'submit'), and an array hint adds itemKey and canReorder. A field without a validationTrigger validates only on submit, so in the Vue and web component examples below an error stays until the next submit.
Submission is a state machine: idle, validating, submitting, submitted. Submit validates an immutable snapshot of the data, and an edit during validating cancels the attempt.
In Vue
pnpm add @texaryn/core @texaryn/schema-json @texaryn/vue vue
@texaryn/vue needs Vue 3.5 or newer. main.ts:
import { createApp } from 'vue'
import { createJsonSchemaAdapter } from '@texaryn/schema-json'
import App from './App.vue'
import { schema } from './schema'
const adapter = await createJsonSchemaAdapter(schema)
createApp(App, { adapter }).mount('#app')
App.vue:
<script setup lang="ts">
import type { SchemaEvaluationPort } from '@texaryn/core'
import {
ErrorSummary,
FormRoot,
createDefaultRegistry,
provideFormRuntime,
useForm,
} from '@texaryn/vue'
const props = defineProps<{ adapter: SchemaEvaluationPort }>()
const form = useForm(props.adapter, {
initialData: { name: '', age: 18 },
onSubmit: async (data) => console.log(data),
})
provideFormRuntime(form.runtime)
const { submission, dispatch } = form
const registry = createDefaultRegistry()
</script>
<template>
<ErrorSummary />
<FormRoot :registry="registry" />
<button
type="button"
:disabled="submission.status === 'validating' || submission.status === 'submitting'"
@click="dispatch({ type: 'Submit' })"
>
Submit
</button>
</template>
The adapter is created in main.ts and passed as a prop, so setup stays synchronous and needs no <Suspense>. provideFormRuntime takes the place of FormProvider, which is why ErrorSummary and FormRoot sit in this component's template: they have to be descendants of the component that provided the runtime.
@texaryn/vue ships render functions, so single-file components are your choice rather than a requirement.
As a web component
pnpm add @texaryn/core @texaryn/schema-json @texaryn/web-components
import { createJsonSchemaAdapter } from '@texaryn/schema-json'
import { createDefaultRegistry, defineTexarynForm } from '@texaryn/web-components'
import type { TexarynFormElement } from '@texaryn/web-components'
import { schema } from './schema'
defineTexarynForm()
const adapter = await createJsonSchemaAdapter(schema)
const form = document.createElement('texaryn-form') as TexarynFormElement
form.registry = createDefaultRegistry()
form.errorSummary = true
form.options = {
initialData: { name: '', age: 18 },
onSubmit: async (data) => console.log(data),
}
form.port = adapter
document.body.append(form)
const submit = document.createElement('button')
submit.type = 'submit'
submit.textContent = 'Submit'
form.querySelector('form')!.append(submit)
defineTexarynForm() registers the element explicitly: importing the package defines nothing. The element renders its own <form novalidate> and turns a submit event into the Submit command, so a type="submit" button inside it works. Set options before port: on an element that's already in the page, options set after port are ignored.
The element emits texaryn-data-change and texaryn-submission-change. Instead of port and options it can take a runtime you created, which it borrows and never destroys, so one runtime can render through several bindings (setting both throws). The package depends on @texaryn/core only and loads no stylesheet.
What JSON Schema it understands
The adapter reads Draft 7, 2019-09 and 2020-12. Draft 4 and Draft 6 aren't supported, and they aren't rejected either: a missing or unrecognized $schema falls back to Draft 7, and createJsonSchemaAdapter(schema, { defaultDialect }) changes that fallback.
Validation is delegated to json-schema-library. The adapter asserts format in Draft 7, which includes a schema with no $schema, and never in 2019-09 or 2020-12, with no option to change that.
The official JSON Schema Test Suite measures that validation, not which keywords get a form control. Mandatory tests passed by @texaryn/schema-json at suite revision f6fd52a, from the conformance page:
| Dialect | Passed | Mandatory tests |
|---|---|---|
| Draft 7 | 917 | 929 |
| 2019-09 | 1,244 | 1,261 |
| 2020-12 | 1,278 | 1,301 |
Every mandatory failure is external schema resolution: the suite expects remote documents from http://localhost:1234, and the adapter has no resolver hook. It fails closed, so an unresolved reference is a $ref validation error rather than a skipped check. The page deliberately gives no combined percentage.
In practice, a schema that $refs another document fails validation wherever data reaches that reference, and a root that is only a remote $ref makes createFormRuntime throw Schema projection missing root node. Bundle it into one document with local $refs first, which also work at the root.
Projection has its own limits. Tuple arrays (prefixItems in 2020-12, or an array-valued items in Draft 7 and 2019-09) get no dedicated projection, patternProperties and additionalProperties generate no fields, and remote $ref, $dynamicRef and $recursiveRef are out of scope. A oneOf or anyOf branch is chosen from the current data, so there's no branch picker, and schema default values aren't written into the data unless you pass initialization: 'schema-defaults'.
Coming from RJSF
| RJSF | Texaryn |
|---|---|
schema prop |
createJsonSchemaAdapter(schema) |
uiSchema |
hints keyed by JSON Pointer |
formData / onChange
|
initialData option, data store |
schema default values in the data |
initialization: 'schema-defaults' (off by default) |
| widgets, templates, fields | renderer registry, WidgetComponent, NodeRenderer
|
| array rendering |
useFieldArray with stable item identity |
liveValidate |
validationTrigger hints |
validate, customValidate
|
no equivalent yet: only the schema validates |
onSubmit |
onSubmit option plus the submission lifecycle |
showErrorList / ErrorList
|
ErrorSummary |
transformErrors, extraErrors
|
no equivalent |
uiSchema: { email: { 'ui:placeholder': '...' } } becomes hints: { '/email': { placeholder: '...' } }. formContext becomes React context or props, and templates become registry entries.
Adding, removing and moving rows keeps each row's identity. When Reset replaces the data, rows are matched to their old identities by value, so an edited row gets a new identity and two identical rows can swap theirs, unless an itemKey hint names a JSON Pointer inside each row, such as '/id', to match on instead. The migration guide covers the rest.
What isn't there yet
-
Stability. It's pre-1.0, and
@texaryn/corewent from its first publish on 30 August 2026 to 0.12.1 on 23 September, so pin exact versions. These examples run with@texaryn/core0.12.1,@texaryn/schema-json0.6.3,@texaryn/react0.5.2,@texaryn/vue0.4.2 and@texaryn/web-components0.4.1. -
Validation beyond the schema. No custom or async validators and no server errors after validation. The runtime accepts a port whose
validatereturns a promise, but the published adapter only validates against the schema. - Widget sets for Vue and Web Components. Bootstrap 5 and Material UI exist for React only.
-
Web component integration. The element renders its own
<form>in light DOM, so it can't sit inside another<form>. It has no Shadow DOM,formAssociatedorElementInternals. -
AI generation tooling. None ships yet; it's on the roadmap. Today a generated schema and hints go through
createJsonSchemaAdapterandhintslike hand-written ones.
Try it
- Documentation, starting with getting started and the architecture.
- Playground: every catalog example through all five renderers.
- Schema details: JSON Schema support and the validation test suite figures.
- Coming from RJSF: the migration guide.
-
API reference, the packages on npm and
llms.txtfor coding agents. - Source and the roadmap.
If you try it and hit a missing extension point, a confusing renderer contract or a schema that doesn't project the way you expect, open an issue.
Originally published at hamidihamza.com.
Top comments (0)