Versions: @rjsf/core v6.6.2 is the current stable release. v5 was a major breaking change from v3/v4 - the validator prop became required and the validator was extracted into @rjsf/validator-ajv8. The code in this article targets v5+. @graphql-codegen/cli v5 is current and the config format shown is compatible.

Internal tooling is where good architecture goes to die. Every backfill, every data-correction operation, every admin action ends up with the same choice: write a one-off form, use the GraphQL playground, or write a script. The first option takes hours and is immediately outdated. The second only works if you let engineers near production data. The third requires a developer every time.

There is a fourth option: since you already have a GraphQL schema, use it. Every mutation already describes its inputs, types, and nullability. That’s enough to generate a form.


The approach

GraphQL has a standard introspection system. Running graphql-codegen with the introspection plugin produces a schema.json that describes every type in the schema. The package graphql-2-json-schema converts that introspection output into JSON Schema format, which react-jsonschema-form can render as a form. It is the maintained successor to the abandoned graphql-to-json-schema package and exports the same fromIntrospectionQuery API - it is a direct drop-in. The examples below vendor the implementation locally for additional control over scalar mapping and argument flattening, but import { fromIntrospectionQuery } from 'graphql-2-json-schema' is a valid alternative.

Three steps:

  1. Generate schema.json from the GraphQL schema
  2. Convert a specific mutation’s arguments to JSON Schema
  3. Render with @rjsf/core

Step 1: generate the introspection file

Add the introspection plugin to your codegen.ts:

import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
    schema: "path/to/schema.graphql",
    generates: {
        './src/graphql/schema.json': {
            plugins: ['introspection']
        },
    }
};

export default config;

Running graphql-codegen produces schema.json. This file is the source for all downstream form generation.


Step 2: convert to JSON Schema

The fromIntrospectionQuery function from graphql-to-json-schema converts the introspection output. The result has a properties.Mutation section containing every mutation and its argument types.

// graphql-form.ts
import type { OperationDefinitionNode, DocumentNode, SelectionNode, FieldNode, IntrospectionQuery } from 'graphql';
import { OperationTypeNode } from 'graphql';
import type { JSONSchema7 } from 'json-schema';
import { reduce } from 'lodash';
import { fromIntrospectionQuery } from './from-introspection-query';
import introspection from '../../graphql/schema.json';

export const getGraphqlJson = (
    name: string,
    operationType: OperationTypeNode
): JSONSchema7 => {
    const jsonSchema = fromIntrospectionQuery(
        introspection as any as IntrospectionQuery,
        { nullableArrayItems: true }
    );

    const root = (
        operationType === OperationTypeNode.MUTATION ? jsonSchema.properties.Mutation :
        operationType === OperationTypeNode.QUERY    ? jsonSchema.properties.Query :
                                                       jsonSchema.properties.Subscription
    ) as JSONSchema7;

    const operation = root.properties[name] as JSONSchema7;
    if (!operation) {
        throw Error(`unknown field ${name} in type ${operationType}`);
    }

    const args = operation.properties.arguments as JSONSchema7;
    operation.properties = reduce<JSONSchema7, { [k: string]: any }>(
        args.properties,
        (prev, curr, k) => { prev[k] = curr; return prev; },
        {}
    );
    operation.required = args.required;
    operation.definitions = jsonSchema.definitions;
    operation.title = name;
    return operation;
};

getGraphqlJson("signUp", OperationTypeNode.MUTATION) returns a JSON Schema that describes the registerInput argument of the signUp mutation, with $ref pointers to the shared type definitions resolved via definitions.

The arguments flattening step is necessary because the introspection output wraps arguments in an extra { return, arguments } structure - we want the arguments directly as form fields.


Step 3: custom scalar types

The utility uses a typesMapping table to convert GraphQL scalar names to JSON Schema types. The defaults cover String, Boolean, Int, Float. Add an entry for every custom scalar your schema defines:

// types-mapping.ts
import type { JSONSchema7 } from 'json-schema';

export const typesMapping: { [k: string]: JSONSchema7 } = {
    Boolean: { type: 'boolean', default: false },
    Float:   { type: 'number' },
    Int:     { type: 'number' },
    Long:    { type: 'number' },
    String:  { type: 'string' },
    ID:      { type: 'string' },
    Decimal: { type: 'number' },
    LocalDate: { type: 'string', format: 'date' },
};

format: 'date' tells react-jsonschema-form to render a date picker for LocalDate fields. Add an entry here for any scalar your schema defines.


Step 4: render the form

import Form from '@rjsf/core';
import validator from '@rjsf/validator-ajv8';
import { OperationTypeNode } from 'graphql';
import { getGraphqlJson } from '../utils/graphql-to-json-schema/graphql-form';

const schema = getGraphqlJson('addTrade', OperationTypeNode.MUTATION);

export function AdminTradeForm() {
    return (
        <Form
            schema={schema}
            validator={validator}
            onSubmit={({ formData }) => {
                // formData is typed according to the mutation arguments
                console.log(formData);
            }}
        />
    );
}

That renders a fully functional form for the addTrade mutation’s inputs, including nested object fields, enums as dropdowns, and date pickers for LocalDate fields. No manual field definitions.


Making it generic

The real value is a single generic component that renders any operation by name:

interface AdminFormProps {
    mutationName: string;
    onSubmit: (data: unknown) => Promise<void>;
}

export function AdminForm({ mutationName, onSubmit }: AdminFormProps) {
    const schema = useMemo(
        () => getGraphqlJson(mutationName, OperationTypeNode.MUTATION),
        [mutationName]
    );

    return (
        <Form
            schema={schema}
            validator={validator}
            onSubmit={({ formData }) => onSubmit(formData)}
        />
    );
}

Add a dropdown listing mutation names pulled from the schema and you have a zero-maintenance admin UI: every new mutation gets a form for free.


Helper utilities

When you need to extract metadata from a DocumentNode (e.g. from a gql tagged template literal):

export const documentToFieldName = (doc: DocumentNode): string => {
    const op = doc.definitions.at(0) as OperationDefinitionNode;
    const selection = op.selectionSet.selections.at(0) as FieldNode;
    return selection.name.value;
};

export const documentToOperationType = (doc: DocumentNode): OperationTypeNode => {
    const op = doc.definitions.at(0) as OperationDefinitionNode;
    return op.operation;
};

These let you pass a gql document directly instead of hardcoding the operation name string:

const ADD_TRADE = gql`mutation AddTrade($input: AddTradeInput!) { addTrade(input: $input) }`;

const schema = getGraphqlJson(
    documentToFieldName(ADD_TRADE),
    documentToOperationType(ADD_TRADE)
);

Caveats

Validation is not included. The generated JSON Schema reflects types and nullability but not business rules. Use the approach from the previous article to expose validation attributes as directives, then layer ajv or @rjsf/validator-ajv8 on top if you need constraint checking in the form.

Custom widgets. react-jsonschema-form has a uiSchema prop for rendering customisation. For anything beyond basic types, you’ll want to map field names or formats to custom widget components.

Schema freshness. The schema.json is generated at build time from the server schema. If the server schema changes and codegen hasn’t run, forms will reflect the old shape. Put codegen in your CI pipeline so this is caught before deployment.


What you get

For any new mutation added to the backend, the admin gets a rendered form within one codegen run, no frontend code required. The field types, nullability, and nested structures all come from the schema. For projects with frequent schema additions - backfill operations, data corrections, one-time migrations - this eliminates a significant class of toil.