Submit to backend
Once a config is wrapped in defineForm, the same InferValues<typeof config> shape threads all the way through the wire: FormRenderer's onSubmit hands you typed values, the server parses the same config with parseSubmission (typed the same way), and a failure — whether a validation error or one your own handler throws — flows back and lands on the exact field that failed. No hand-written type sits between client and server; both read it off the one config.
Variant A: Server Action via createFormAction
createFormAction(config, handler) wraps parseSubmission around a Server Action: it parses the incoming body against config, and only calls your handler once parsing succeeds — with the typed, validated payload.
// config.ts
import { defineForm } from "@/form-builder";
export const signupConfig = defineForm({
id: "signup",
fields: [
{ type: "email", name: "email", required: true },
{ type: "submit", name: "submit", text: "Sign up" },
],
});// action.ts
"use server";
import { createFormAction } from "@/form-builder/next";
import { signupConfig } from "./config";
import { createAccount, findAccountByEmail } from "@/lib/accounts"; // your own persistence
export const signup = createFormAction(signupConfig, async (values) => {
// `values` is InferValues<typeof signupConfig> — no cast, no re-parsing.
const existing = await findAccountByEmail(values.email);
if (existing) {
// A thrown { fieldErrors?, formError? } is funneled into the action's
// error result. Anything else thrown re-throws — genuine errors are
// never swallowed.
throw { fieldErrors: { email: "That email is already registered." } };
}
const account = await createAccount(values);
return { ok: true as const, accountId: account.id };
});The result is { ok: true } & Omit<R, "ok"> on success (whatever extra keys your handler returned, alongside ok) or { ok: false, errors: ServerErrorResult } on failure — from either a parse failure or a thrown field error, unified into the same shape.
// page.tsx
"use client";
import { FormRenderer } from "@/form-builder";
import { signupConfig } from "./config";
import { signup } from "./action";
export function SignupForm() {
return (
<FormRenderer
config={signupConfig}
onSubmit={async (values) => {
const res = await signup(values);
if (res.ok) return; // res.accountId is available here, typed
return res.errors; // FormRenderer applies it to the failing field(s)
}}
/>
);
}createFormAction lives under form-builder/next, not the root form-builder barrel — core stays framework-agnostic, and this is the one piece that knows about Server Actions.
Variant B: Route Handler via parseSubmission
createFormAction is a thin convenience wrapper — calling parseSubmission directly works the same way and fits any transport, not just a Server Action: a Route Handler, a queue consumer, a third-party webhook relay. The success key is values, typed as InferValues<typeof config> from the generic config argument — the same type createFormAction hands its handler.
// app/api/signup/route.ts
import { parseSubmission } from "@/form-builder";
import { signupConfig } from "./config";
import { createAccount, findAccountByEmail } from "@/lib/accounts";
export async function POST(request: Request) {
const result = parseSubmission(signupConfig, await request.json());
if (!result.ok) return Response.json(result.errors, { status: 400 });
// result.values is InferValues<typeof signupConfig> — typed, no cast.
const existing = await findAccountByEmail(result.values.email);
if (existing) {
return Response.json(
{ fieldErrors: { email: "That email is already registered." } },
{ status: 400 },
);
}
await createAccount(result.values);
return Response.json({ ok: true });
}A field error you construct by hand, like the duplicate-email check above, only needs to match ServerErrorResult's shape ({ fieldErrors?, formError? }) — it doesn't have to come from parseSubmission itself to be applied correctly on the client.
Client: applying server errors
With FormRenderer, this is automatic — its onSubmit prop accepts void | ServerErrorResult | Promise<...>. Return the errors object from either variant above as-is and FormRenderer calls applyServerErrors internally, mapping fieldErrors to a setErrorcall per field (jumping to that field's wizard step and focusing it, if the form has steps) and formErrorto the form's root error slot. No extra client-side glue needed for that path.
Building a custom submit UI directly on useDynamicForm instead of FormRenderer? Call applyServerErrorsyourself — it's the same function, exported for exactly this:
// Driving your own submit UI on top of useDynamicForm — no FormRenderer.
"use client";
import { useDynamicForm, applyServerErrors } from "@/form-builder";
import { signupConfig } from "./config";
import { signup } from "./action";
export function useSignupForm() {
const { form } = useDynamicForm(signupConfig);
const onSubmit = form.handleSubmit(async (values) => {
const res = await signup(values);
if (res.ok) return;
// Same mapping FormRenderer runs internally: fieldErrors -> setError
// per field, formError -> the returned root-level message.
const { formError } = applyServerErrors(form.setError, res.errors, signupConfig.fields);
if (formError) form.setError("root", { type: "server", message: formError });
});
return { form, onSubmit };
}See Server-side validation for the full parseSubmission contract this page builds on — the otp fail-closed pattern, file uploads, custom field types, and every documented sharp edge apply here unchanged; createFormAction and the typed values key are additive, not a different code path. See Type safety for where the InferValues<typeof config> shape flowing through this page comes from. ADR-0004 records the pinned server-validation rulings this design inherits.