BuildBaseBuildBase

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.

// 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';
}

Warning

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.

Before you start

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:

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), so whatever shape you send is what gets stored.

Handling the two error shapes

StatusBodyCause
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:

// 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 — validation rules and the form.submitted event.
  • Workflows — send a confirmation email on submission.