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? }widget | Render | Send as _captcha |
|---|---|---|
turnstile | Cloudflare Turnstile with siteKey | The token from its callback |
hcaptcha | hCaptcha with siteKey | The token from its callback |
recaptcha | reCAPTCHA v2 Checkbox with siteKey | The token from its callback |
pow | Nothing 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
| Status | Body | Cause |
|---|---|---|
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
- Spam protection - providers, rules and the review queue.
- Forms overview - validation rules and the
form.submittedevent. - Workflows - send a confirmation email on submission.