Article
Making CRUD feel Euphoric with Dynamic Forms - even in GraphQL
When I launched
ecoeats
in February 2020, I chose GraphQL on NestJS for our API. It helped me build clients across seven different apps, with
graphql-zeus
keeping their types in sync with the schema.
Lovely. Except I was still writing the same bloody form over and over again.
GraphQL made fetching data pleasant. It didn't make creating an input, validating it, saving it, and explaining the errors to a human disappear. REST wouldn't have made that work disappear either.
This is a retrospective on the schema-driven forms I built for ecoeats, followed by a small illustrative implementation. The new examples explain the pattern; they aren't a release of the original service or a tested, drop-in application. Updated 10 September 2026.
We begin with a dashboard
It was a requirement from day 1 to be able to see an overview of what went on inside the ecoeats Platform. We needed to know how many orders we had, how many riders were online and whether our merchants had their tablets turned on.
One of the earliest problems I had was the absolute tedium of creating GraphQL endpoints for all of these tasks. Repetitive, uninteresting tables. Create object forms. Edit forms for Rider's birthdays. Each and every one with custom client input validation
and
server side validation. By the start of 2021 we had a shedload of ObjectTypes, InputTypes and Mutations. So much busywork getting in the way of actually solving the hard problems I wanted to get on with.
You want everything to be configurable when needed, but exposing your (highly trained) support and operations staff to your actual database is never quite the right choice.
There had to be a better way
One option was to serve HTML forms and submit them with a standard
<form>
element. A perfectly reasonable solution, but it didn't fit neatly into the interactive dashboard and component library we already had.
The idea was simple: describe the editable data once, use that description to render ordinary controls, and leave the actual business operation on the server.
So, what is a dynamic form?
The dynamic form I designed is composed of a few key parts. In GraphQL, the specification looks like this:
Most of the form's attributes are regular GraphQL fields. The interesting pair is
initialValues
, containing the starting data, and
schema
, describing valid input. A separate
uiSchema
can suggest widgets and layout without confusing presentation with validation.
Having a JSONSchema that describes an entity allows you to construct an arbitrary display / input method for any instance of that entity. As a JSONSchema property might define something like the below simple name entry;
{"type": "object","additionalProperties": false,"required": ["name"],"properties": {"name": {"type": "string","title": "Name","minLength": 3,"maxLength": 120}}}
You can see how this would map on to describe a single piece of JSON such as;
{"name": "Hello"}
Using the schema as a building block for entity representation, I set out to create forms and tables out of this schema and some values alone.
Building dynamic forms in NestJS
The registry
I used custom decorators to register form loaders and submission handlers with a central registry. These were application code, not built-in NestJS decorators. Conceptually, their signatures looked like this:
DynamicFormLoader(() => EntityFunc, FormID, roles)
The loader found the initial values for an entity, if one was supplied. It also checked authorization: being logged in tells us who someone is, not whether they may edit this particular object.
DynamicFormHandler(FormID, { roles })
Adding these decorators to a NestJS service causes them to be hooked in to a global
DynamicFormRegistry
(we can discuss this another time) at application bootstrap, allowing NestJS to lookup the correct handler for a given FormID when requested from the client side.
Here's an example of a loader/handler pair in our Charity Handling service:
Saving arbitrary form input directly to a database would be risky. Generating a schema doesn't remove that risk. The submission path needs its own validation and authorization, regardless of what the browser has already checked.
Generating schemas from validation rules
class-validator-jsonschema
connects class-validator metadata to JSON Schema.
This package has made creating JSON Schemas for various forms super easy. The entities used in the above service had their JSON Schema generated directly from the class, like below:
A bit decorator heavy, eh? That historical class also described the charity exposed through GraphQL, which explains the extra
@Field
decorators. A cleaner boundary is a dedicated input DTO containing only editable fields. TypeScript's
Pick
or
implements
alone won't copy runtime validation metadata; the input class still needs its validation decorators.
On submission, class-transformer constructs the input instance and class-validator checks it. Transformation and validation are separate steps: constructing a class does not make its contents safe.
Here's a small NestJS service illustrating that boundary. Its
save
callback represents an application operation, not an unrestricted repository write:
import { BadRequestException, Injectable } from '@nestjs/common';import { plainToInstance } from 'class-transformer';import { IsString, Length, validate } from 'class-validator';class RenameInput {@IsString()@Length(3, 120)name!: string;}@Injectable()export class RenameFormHandler {async submit(raw: unknown,authorize: () => Promise<void>,save: (input: { name: string }) => Promise<void>,): Promise<void> {await authorize(); // Must throw if this actor cannot edit this entity.if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {throw new BadRequestException('Expected a form object');}const input = plainToInstance(RenameInput, raw);const errors = await validate(input, {whitelist: true,forbidNonWhitelisted: true,forbidUnknownValues: true,validationError: { target: false, value: false },});if (errors.length > 0) {throw new BadRequestException({message: 'Check the highlighted fields',fields: errors.map(error => ({field: error.property,messages: Object.values(error.constraints ?? {}),})),});}await save({ name: input.name });}}
The registry chooses the handler from a server-owned allowlist. The authenticated actor comes from the request context, never from a submitted
userId
. The application service must also enforce entity ownership, business invariants, and concurrency rules at the write boundary.
For nested DTOs, validate nested values explicitly and map errors recursively. For JSON Schema generation, check that custom validators and cross-field rules survive the conversion; some rules can only be enforced on the server.
Rendering with react-jsonschema-form
Now we have a schema, how do we turn it into something a person can use?
react-jsonschema-form
handles ordinary fields and client-side validation. It also supports themes; our dashboard used an antd-based theme.
For a minimal React example, install compatible versions of
@rjsf/core
,
@rjsf/utils
, and
@rjsf/validator-ajv8
. This component receives a loaded form and an application-specific submission function:
import { useState } from 'react';import Form from '@rjsf/core';import type { RJSFSchema } from '@rjsf/utils';import validator from '@rjsf/validator-ajv8';type Values = { name?: string };type Definition = {id: string;schema: RJSFSchema;initialValues: Values;};export function DynamicForm({definition,submit,}: {definition: Definition;submit: (id: string, values: Values) => Promise<void>;}) {const [values, setValues] = useState(definition.initialValues);const [saving, setSaving] = useState(false);const [message, setMessage] = useState('');return (<><Form<Values>schema={definition.schema}validator={validator}formData={values}disabled={saving}onChange={({ formData }) => setValues(formData ?? {})}onSubmit={async ({ formData }) => {setSaving(true);setMessage('');try {await submit(definition.id, formData ?? {});setMessage('Saved');} catch {setMessage('Could not save. Your changes are still here.');} finally {setSaving(false);}}}><button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</button></Form><p role="status">{message}</p></>);}
Mount this with a key that changes when switching entity or form version, so one form's local state doesn't leak into another. Keep field-level server errors separate from transport failures and pass them into RJSF's error facilities. The short example above shows only the generic failure path.
The GraphQL boundary
The outer contract remains typed. The schema and values travel through an explicitly registered JSON scalar. For example, a simplified schema could expose:
scalar JSONtype DynamicForm {id: ID!version: Int!title: String!schema: JSON!initialValues: JSON!}type FormResult {success: Boolean!}type Query {dynamicForm(id: ID!, entityId: ID): DynamicForm!}type Mutation {submitDynamicForm(id: ID!entityId: IDversion: Int!values: JSON!): FormResult!}
That SDL is illustrative: implement its resolvers, register the JSON scalar, and connect them to the registry. Neither GraphQL nor NestJS knows what an arbitrary form's JSON should contain without those handlers.
A valid query against that contract looks like this:
query GetDynamicForm($id: ID!, $entityId: ID) {dynamicForm(id: $id, entityId: $entityId) {idversiontitleschemainitialValues}}
For example, pass
{"id":"MenuOffer","entityId":null}
as variables. Submit the edited values with:
mutation SubmitDynamicForm($id: ID!$entityId: ID$version: Int!$values: JSON!) {submitDynamicForm(id: $identityId: $entityIdversion: $versionvalues: $values) {success}}
The server chooses its own schema and checks the submitted version. Never accept the browser's copy of the schema as the validation authority. A schema version prevents silently interpreting old inputs using new rules; entity revisions are a separate concern if concurrent edits could overwrite each other.
A full dynamic form is born
Now we have a gorgeous form generated entirely from our DynamicForm endpoint, we pass it in to RJSF and
bam!
This is a screenshot of our Menu Offer creation form that merchants use to create offers for their customers.
Nesting forms without nesting the entire database
A challenge we faced was building forms that referred to other entities. An
enum
is fine for a small, fixed set of values. It's a poor home for ten thousand menu items with changing permissions.
In the above form, clicking on the 'Restrict to specific items' button opens
another nested form
, that allows the user to select up to X number of items using the
same JSONSchema information from the parent form
, including validation options like 'maxItems'.
Our dynamic table shared rendering code with the forms and accepted pagination parameters through the loader. Images could be displayed alongside copyable identifiers.
For this pattern, keep the picker separate from the selected value: store stable IDs in the form, search and paginate options on demand, and recheck every selected ID on submission. A client-side
maxItems
rule improves feedback; server-side validation enforces it.
GraphQL SDL has no parameterised generic types such as
Page<T>
. You can generate concrete page types in application code, or use a generic JSON-backed table with an explicit column description. The second option buys flexibility by giving up field-level GraphQL typing. That's a trade-off, not a loophole.
Where this is worth the trouble
Our configurable order-export tools were another good fit. A schema and a handler could replace a bespoke dashboard screen.
But hiding the details inside a JSON field doesn't make an operation private. It still needs authorization, payload limits, audit logging, and abuse protection. It also becomes less discoverable to generated clients.
I'd use this for repetitive admin workflows with ordinary fields and clear business operations. I wouldn't force a carefully designed checkout or a highly interactive editor through it just to avoid writing components.
Before shipping, test rejected unknown fields, invalid nested values, unauthorized entities, deleted relation targets, stale versions, duplicate submissions, and failed saves. Confirm that client and server agree on the JSON Schema dialect and validator behaviour.
Less CRUD, not less responsibility
The satisfying part wasn't making every screen generic. It was making the boring screens cheap, so the unusual ones could receive some actual attention.
I wasn't completely happy with the original nesting API, and this article doesn't promise an open-source release of that implementation. What it does offer is a reusable boundary: the server owns the operation and its rules; the client turns a constrained description into a usable form.
One schema, a small renderer, and considerably less copy-and-paste. That's the euphoric bit.
References