# Custom forms

Fetch the field schema and render your own form UI against the public endpoints.

The `/fields` endpoint returns the published field schema, so you can render a
form in your own design system and keep the console as the source of truth for
what the fields are.

```tsx
// app/contact/contact-form.tsx
'use client';

import { useEffect, useState } from 'react';

type FieldType =
  | 'text'
  | 'rich-text'
  | 'number'
  | 'bool'
  | 'date'
  | 'color'
  | 'link'
  | 'email';

type Field = {
  slug: string;
  title: string;
  helpText: string;
  type: FieldType;
  required: boolean;
  defaultValue: unknown;
};

const BASE = 'https://api.console.buildbase.app/api/forms/public';

export function ContactForm({
  orgId,
  formId,
}: {
  orgId: string;
  formId: string;
}) {
  const [fields, setFields] = useState<Field[]>([]);
  const [errors, setErrors] = useState<string[]>([]);
  const [done, setDone] = useState(false);

  useEffect(() => {
    fetch(`${BASE}/${orgId}/${formId}/fields`)
      .then((r) => r.json())
      .then(setFields);
  }, [orgId, formId]);

  async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setErrors([]);

    const form = new FormData(event.currentTarget);
    const data = Object.fromEntries(form.entries());

    const response = await fetch(`${BASE}/${orgId}/${formId}/submit`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });

    if (response.ok) {
      setDone(true);
      return;
    }

    const body = await response.json();
    setErrors(body.errors ?? [body.message]);
  }

  if (done) return <p>Thanks — we will be in touch.</p>;

  return (
    <form onSubmit={onSubmit}>
      {fields.map((field) => (
        <label key={field.slug}>
          {field.title}
          {field.type === 'rich-text' ? (
            <textarea name={field.slug} required={field.required} />
          ) : (
            <input
              name={field.slug}
              type={inputType(field.type)}
              required={field.required}
            />
          )}
          {field.helpText && <small>{field.helpText}</small>}
        </label>
      ))}

      {errors.map((error) => (
        <p key={error} role="alert">
          {error}
        </p>
      ))}

      <button type="submit">Send</button>
    </form>
  );
}

function inputType(type: FieldType) {
  if (type === 'email') return 'email';
  if (type === 'number') return 'number';
  if (type === 'date') return 'date';
  if (type === 'link') return 'url';
  if (type === 'color') return 'color';
  if (type === 'bool') return 'checkbox';
  return 'text';
}
```

> **Note:**
  The boolean type's wire value is **`bool`**, not `boolean`, and rich text is
  **`rich-text`**, not `richText`. Matching on the wrong string silently falls
  through to a plain text input rather than raising an error, so check these two
  against the `/fields` response rather than guessing.


> **Note:**
  Both endpoints are public, so this runs client-side with no token. The `orgId`
  and `formId` are safe to expose — they only grant access to this form's schema
  and its submit endpoint.


## Numbers and booleans need care

`FormData` stringifies everything. The server accepts numeric strings, so the
example above submits successfully — but the stored record then holds `"12"`
rather than `12`.

Coerce before submitting if you want typed records:

```tsx
const data = Object.fromEntries(
  fields.map((field) => {
    const raw = form.get(field.slug);
    if (field.type === 'number') return [field.slug, Number(raw)];
    if (field.type === 'bool') return [field.slug, raw === 'on'];
    return [field.slug, raw];
  })
);
```

An unchecked checkbox is absent from `FormData` entirely, which is why the
`bool` branch reads `raw === 'on'` rather than looking for `'false'`.

Coercing booleans yourself matters more than it looks: the server does not
validate them at all (see [validation](/forms/overview)), so whatever shape you
send is what gets stored.

## Handling the two error shapes

| Status | Body                                                              | Cause                                       |
| ------ | ----------------------------------------------------------------- | ------------------------------------------- |
| `400`  | `{ success: false, message: 'Validation failed', errors: [...] }` | One or more fields failed schema validation |
| `404`  | `{ error: true, message: 'Form with id ... not found' }`          | Wrong `formId`, or the form was deleted     |

Reading `body.errors ?? [body.message]` covers both, which is what the example
does.

## Server-side submission

Submitting from your backend instead keeps the form IDs private and gives you a
place to run bot protection:

```typescript
// app/api/contact/route.ts
export async function POST(request: Request) {
  const data = await request.json();

  // your own spam check runs here

  const response = await fetch(
    `${process.env.BUILDBASE_API}/api/forms/public/${process.env.ORG_ID}/${process.env.FORM_ID}/submit`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    }
  );

  return Response.json(await response.json(), { status: response.status });
}
```

The endpoint is unauthenticated either way — proxying does not add a security
check, it gives you somewhere to put one.

## Next Steps

- [Forms overview](/forms/overview) — validation rules and the `form.submitted` event.
- [Workflows](/workflows/overview) — send a confirmation email on submission.
