Skip to main content

Let the Backend Own the Form

Every time a business requirement changes a form field, someone has to open a PR, get it reviewed, merge it, and deploy. For teams with many forms or frequent iterations, this cycle adds up fast.

The alternative: your backend stores form definitions and the frontend renders whatever it receives. Adding a field is a backend change. No frontend deploy needed.

The problem with hardcoded schemas

When form schemas live in the frontend, you get:

  • A deploy cycle for every field change, label tweak, or validation update
  • Different tenants or user roles can't see different form variants without feature flags
  • A/B testing form layouts means branching frontend code
  • The backend validates with one set of rules, the frontend with another — they drift

The root cause is that the frontend is making decisions it doesn't need to make.

What backend-driven forms look like

Your backend returns a RemoteFormPayload — a versioned JSON object describing the form. The frontend validates it, creates a runtime, and renders.

GET /api/forms/onboarding

RemoteFormPayload (JSON)

parseRemoteFormPayload() ← validates the shape

createFormRuntime({ form: payload.form })

<FormRuntimeRoot />

The frontend doesn't know what fields exist. It renders whatever arrives.

API contract

Your backend endpoint must return a RemoteFormPayload. Formwright validates the shape on load — if the backend returns something malformed, you get a typed error with the exact path that failed, not a cryptic runtime crash.

GET /api/forms/onboarding
{
"version": "1.0",
"form": {
"version": "1.0",
"formId": "onboarding",
"dataSchema": {
"rootType": "object",
"fields": {
"companyName": { "valueType": "string", "required": true },
"teamSize": { "valueType": "integer" }
}
},
"uiSchema": {
"nodes": {
"companyName": { "fieldType": "text", "label": "Company name" },
"teamSize": { "fieldType": "number", "label": "Team size" }
},
"layout": {
"type": "stack",
"id": "root",
"children": [
{ "type": "field", "ref": "companyName" },
{ "type": "field", "ref": "teamSize" }
]
}
}
},
"initialValues": { "companyName": "Acme Corp" },
"meta": {
"schemaId": "onboarding",
"schemaVersion": "2026-05-01"
}
}

initialValues pre-fills fields. meta is forwarded to the submit payload so the backend knows which version was rendered.

The DynamicForm component

Split loading from rendering — useCreateFormRuntime must be called after the schema arrives.

components/DynamicForm.tsx
import {
FormRuntimeProvider,
FormRuntimeRoot,
useCreateFormRuntime,
useRemoteFormDefinition,
} from "formwright/react";
import { registerBasicPlugins } from "formwright/plugins";
import { registerAsyncPlugins } from "formwright/plugins-async";
import { loadRemoteForm, RemoteSchemaValidationError } from "formwright/remote";
import type { RemoteFormPayload } from "formwright/remote";

interface DynamicFormProps {
formId: string;
onSuccess?: (formId: string) => void;
}

export function DynamicForm({ formId, onSuccess }: DynamicFormProps) {
const { status, payload, error, isFetching } = useRemoteFormDefinition({
key: `form:${formId}`,
loader: ({ signal }) =>
loadRemoteForm({ url: `/api/forms/${formId}`, init: { signal } }),
staleTimeMs: 5 * 60 * 1000,
retry: 1,
});

if (status === "loading") return <FormSkeleton />;

if (status === "error") {
if (error instanceof RemoteSchemaValidationError) {
// In development, show the exact path that failed.
// In production, show a generic message.
return <SchemaError issues={error.issues} />;
}
return <p>Failed to load form: {error?.message}</p>;
}

if (!payload) return null;

return <LoadedForm payload={payload} isFetching={isFetching} onSuccess={onSuccess} />;
}

// Separate component because useCreateFormRuntime must be called
// after the schema is available — not conditionally.
function LoadedForm({
payload,
isFetching,
onSuccess,
}: {
payload: RemoteFormPayload;
isFetching: boolean;
onSuccess?: (formId: string) => void;
}) {
const runtime = useCreateFormRuntime({
form: payload.form,
plugins: [...registerBasicPlugins(), ...registerAsyncPlugins()],
});

async function handleSubmit(values: Record<string, unknown>) {
const response = await fetch(`/api/forms/${payload.form.formId}/submit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ values, meta: payload.meta }),
});

if (!response.ok) throw new Error("Submission failed");
onSuccess?.(payload.form.formId);
}

return (
<>
{isFetching && <p className="text-sm text-muted-foreground">Updating schema…</p>}
<FormRuntimeProvider
runtime={runtime}
initialValues={payload.initialValues}
hiddenFieldPolicy="clear"
onSubmit={handleSubmit}
>
<FormRuntimeRoot rootLayoutId="root" />
</FormRuntimeProvider>
</>
);
}

isFetching is true when a background refresh is in progress — the form is still usable, just showing a "refreshing" indicator.

Wire to a router

React Router

pages/FormPage.tsx
import { useParams, useNavigate } from "react-router-dom";
import { DynamicForm } from "@/components/DynamicForm";

export function FormPage() {
const { formId } = useParams<{ formId: string }>();
const navigate = useNavigate();

return (
<DynamicForm
formId={formId!}
onSuccess={() => navigate(`/forms/${formId}/confirmation`)}
/>
);
}

// Route: <Route path="/forms/:formId" element={<FormPage />} />

Next.js App Router

app/forms/[formId]/page.tsx
import { DynamicForm } from "@/components/DynamicForm";

export default function FormPage({ params }: { params: { formId: string } }) {
return <DynamicForm formId={params.formId} />;
}

Schema validation errors

parseRemoteFormPayload() throws RemoteSchemaValidationError when the payload shape is wrong. Each issue has a path pointing to the failing field.

components/SchemaError.tsx
import type { RemoteSchemaIssue } from "formwright/remote";

export function SchemaError({ issues }: { issues: RemoteSchemaIssue[] }) {
return (
<div role="alert">
<p>This form could not be loaded.</p>
{process.env.NODE_ENV === "development" && (
<ul className="mt-2 text-sm font-mono">
{issues.map((issue, i) => (
<li key={i}>
<code>{issue.path}</code>: {issue.message}
</li>
))}
</ul>
)}
</div>
);
}

Dev shows the exact path. Production shows a generic message. Useful when iterating on backend schema payloads — you know immediately which field definition is wrong.

Send schema version with submissions

Including meta in the submit body lets the backend check whether the form version the user filled out matches the current schema — and reject or migrate submissions that arrive against a stale version.

POST /api/forms/onboarding/submit

{
"values": { "companyName": "Acme", "teamSize": 12 },
"meta": {
"schemaId": "onboarding",
"schemaVersion": "2026-05-01"
}
}

What changes and what stays the same

What changes:

  • Form field definitions move to the backend — no frontend code change for field additions or label edits
  • Different tenants/roles can receive different schemas from the same endpoint
  • The deploy cycle for form changes is backend-only

What stays the same:

  • The frontend rendering engine is the same — FormRuntimeProvider, FormRuntimeRoot, your custom renderers all work unchanged
  • Validation still runs client-side, driven by the schema the backend provides
  • You still control styling, slots, and renderer maps in the frontend

See also

  • Remote Schemas — caching, invalidation, RPC and GraphQL patterns
  • Remote APIloadRemoteForm, parseRemoteFormPayload, error types
  • React APIuseRemoteFormDefinition, useCreateFormRuntime