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'
  | 'file';

type Field = {
  slug: string;
  title: string;
  helpText: string;
  type: FieldType;
  required: boolean;
  defaultValue: unknown;
  maxMb?: number; // file fields: the largest file, 5 when unset
  maxFiles?: number; // file fields: how many files, 1 when unset
};

const BASE = 'https://api.console.buildbase.app/api/forms/public';
const IMAGES = 'image/png,image/jpeg,image/gif,image/webp,image/svg+xml';

export function ContactForm({
  orgId,
  formId,
}: {
  orgId: string;
  formId: string;
}) {
  const [fields, setFields] = useState<Field[]>([]);
  // The upload ids of each file field, in the order they were picked.
  const [uploads, setUploads] = useState<Record<string, string[]>>({});
  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 onFiles(field: Field, files: FileList | null) {
    setErrors([]);
    try {
      const picked = Array.from(files ?? []).slice(0, field.maxFiles ?? 1);
      const ids = await Promise.all(
        picked.map((file) => uploadFile(orgId, formId, field.slug, file))
      );
      setUploads((current) => ({ ...current, [field.slug]: ids }));
    } catch (error) {
      setErrors([(error as Error).message]);
    }
  }

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

    const form = new FormData(event.currentTarget);
    const data: Record<string, unknown> = Object.fromEntries(form.entries());
    // A file field carries upload ids, never the file: one id for a field
    // that takes one file, a list for a field that takes more.
    for (const field of fields) {
      if (field.type !== 'file' || !uploads[field.slug]?.length) continue;
      const ids = uploads[field.slug];
      data[field.slug] = (field.maxFiles ?? 1) > 1 ? ids : ids[0];
    }

    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 === 'file' ? (
            // No `name`: the file must not travel in the submit body.
            <input
              type="file"
              accept={IMAGES}
              multiple={(field.maxFiles ?? 1) > 1}
              required={field.required && !uploads[field.slug]?.length}
              onChange={(e) => onFiles(field, e.currentTarget.files)}
            />
          ) : 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>
  );
}

/** Intent, PUT straight to storage, complete. Answers the upload id. */
async function uploadFile(
  orgId: string,
  formId: string,
  field: string,
  file: File
): Promise<string> {
  const intent = await fetch(`${BASE}/${orgId}/${formId}/uploads`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      field,
      name: file.name,
      size: file.size,
      contentType: file.type,
    }),
  });
  const signed = await intent.json();
  if (!intent.ok) throw new Error(signed.message);

  const put = await fetch(signed.url, {
    method: signed.method,
    headers: signed.headers,
    body: file,
  });
  if (!put.ok) throw new Error('The upload did not finish. Try again.');

  const complete = await fetch(
    `${BASE}/${orgId}/${formId}/uploads/${signed.uploadId}/complete`,
    { method: 'POST' }
  );
  if (!complete.ok) throw new Error((await complete.json()).message);
  return signed.uploadId;
}

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.

File fields

A file field's value is never the file. Each picked file goes through the form's three upload steps first - uploadFile above - and the field carries the upload id that answers, or a list of ids when its maxFiles is above 1. Sending the file, or its name, is refused with 400 and "... must be an uploaded file".

Check maxMb before uploading: a larger file is refused with 413 and code UPLOAD_TOO_LARGE at the first step, before a byte is sent. Every limit and refusal is in File fields.

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'. A file field has no entry in FormData either: add its upload ids afterwards, as the example above does.

Coercing booleans yourself matters more than it looks: the server accepts "true" and "false" as well as true and false (see validation), and stores whichever shape you send.

Captcha in your own UI

When a form has Require a captcha on, every submission must carry a token in a _captcha key next to the field values. BuildBase strips it before it validates or stores anything, so it never appears in a record or a webhook.

Read which widget to render from the form itself:

const form = await fetch(`${BASE}/${orgId}/${formId}`).then((r) => r.json());
// form.captcha: null, or { widget, siteKey? }
widgetRenderSend as _captcha
turnstileCloudflare Turnstile with siteKeyThe token from its callback
hcaptchahCaptcha with siteKeyThe token from its callback
recaptchareCAPTCHA v2 Checkbox with siteKeyThe token from its callback
powNothing visible. Fetch /challenge and solve it (below)The base64 solution

Warning

A provider widget only renders on hostnames its key allows. The Managed key is BuildBase's and allows only the console's hostname, so a form rendered on your own domain needs your own keys (with your domain added to the widget) or the Built-in check, which works on any domain. The embed avoids this entirely.

The built-in check, in full:

async function solveChallenge(orgId: string, formId: string) {
  const res = await fetch(`${BASE}/${orgId}/${formId}/challenge`);
  const c = await res.json();
  const encoder = new TextEncoder();
  for (let n = 0; n <= c.maxnumber; n++) {
    const digest = await crypto.subtle.digest(
      'SHA-256',
      encoder.encode(`${c.salt}${n}`)
    );
    const hex = Array.from(new Uint8Array(digest))
      .map((b) => b.toString(16).padStart(2, '0'))
      .join('');
    if (hex === c.challenge) {
      return btoa(
        JSON.stringify({
          algorithm: c.algorithm,
          challenge: c.challenge,
          number: n,
          salt: c.salt,
          signature: c.signature,
        })
      );
    }
  }
  throw new Error('No solution');
}

// in onSubmit
const _captcha = await solveChallenge(orgId, formId);
body: JSON.stringify({ ...data, _captcha });

A challenge is single use, belongs to one form and expires after 30 minutes, so fetch a fresh one for each attempt. crypto.subtle needs https (or localhost).

The hidden bot trap

The hosted page also sends _hp, the value of an input that people never see. Anything in it marks the submission as a bot: it is answered with success and never stored. Your UI can do the same:

<div aria-hidden="true" style={{ position: 'absolute', left: '-10000px' }}>
  <label htmlFor="bb-hp">Leave this field empty</label>
  <input id="bb-hp" name="_hp" tabIndex={-1} autoComplete="off" />
</div>

Give it a name no browser autofill recognises. A field called website or email gets filled in for real people, who would then be dropped silently.

Handling errors

StatusBodyCause
400{ success: false, message: 'Validation failed', errors: [...] }One or more fields failed schema validation
400{ success: false, message: 'Captcha verification failed...' }Missing, expired, reused or wrong token
400{ success: false, message: 'Please use a different email...' }A blocked or throwaway email domain
400{ success: false, message: 'Your submission could not...' }A content rule set to reject
404{ error: true, message: 'Form with id ... not found' }Wrong formId, or the form is a draft or archived
429{ success: false, message: 'Too many submissions...' }A rate limit, the form's daily cap or your plan limit
503{ success: false, message: 'Could not verify the captcha...' }The captcha provider was unreachable; retry

Reading body.errors ?? [body.message] covers all of them, which is what the example does. After any failure, reset the widget or fetch a new challenge: tokens are single use.

Server-side submission

Submitting from your backend instead keeps the form IDs private. Content rules, the daily cap and the review queue still apply. Leave Require a captcha off for such a form unless your frontend collects a token and your backend passes it through as _captcha:

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

  // any extra check of your own 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. Rate limits are per IP, and every proxied submission arrives from your server's one address, so a busy proxied form meets the per-IP limit sooner; the captcha and content rules are the protection that scales.

Next Steps