Skip to content

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)
      }}
    />
  );
}
form-builder/next is a separate entry point

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.