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 API — loadRemoteForm, parseRemoteFormPayload, error types
  • React API — useRemoteFormDefinition, useCreateFormRuntime