Forms Runtime
There are two form concepts in @businessdash/sdk 0.9.50. They solve different
parts of the problem:
| Concept | Source | Output |
|---|---|---|
| Unified orchestration | Local collection() + bd + layout containers | A database schema and immutable uiFormTree definition |
| Published forms runtime | A Live form loaded from BusinessDash | Live values, errors, steps, visibility, uploads, and submission state |
This page documents the published forms runtime. For the local schema/validation/layout compiler, see Unified Form Orchestration.
uiFormTree and the runtime FormSchema are not interchangeable types in
0.9.50. createFormController() loads a published BusinessDash form schema; it does
not consume the orchestration output directly.
Make a dashboard form Live
For forms.schema(slug) or forms.submit(slug, data) to find a dashboard form,
the form's Live switch must be on.
- Live allows SDK schema reads and submissions.
- Chatbot access only controls whether the chatbot may surface the form.
- Public link publishes the form on a BusinessDash-hosted share page.
A not_found result most often means the form is missing or not Live.
Fastest path: <BdForm>
The React binding loads the published schema, renders the nested field tree, validates locally, manages steps and conditions, uploads files, and submits.
import { BdForm } from '@businessdash/sdk/react'
export function ContactForm() {
return (
<BdForm
slug="contact"
token={process.env.NEXT_PUBLIC_BD_PUBLISHABLE_KEY!}
siteId={process.env.NEXT_PUBLIC_BD_SITE_ID!}
submitOptions={{ source: 'marketing-site' }}
onSuccess={(result) => {
console.log(result.submissionId)
}}
/>
)
}
Use a publishable pk_… token in browser code. It is origin-locked,
rate-limited, and scoped to public operations. Never put the secret
BD_API_KEY in a client bundle.
Provide the client once when several forms share it:
import { BdForm, BdFormProvider } from '@businessdash/sdk/react'
<BdFormProvider
token={process.env.NEXT_PUBLIC_BD_PUBLISHABLE_KEY!}
siteId={process.env.NEXT_PUBLIC_BD_SITE_ID!}
>
<BdForm slug="contact" />
<BdForm slug="quote-request" />
</BdFormProvider>
You can also pass any client exposing forms.schema(), forms.submit(), and
optionally forms.uploadFile() / forms.scheduleSlots(). This is the
recommended shape for a same-origin proxy.
Native bindings also ship for Vue, Svelte, Solid, Qwik, and Angular. Vanilla
applications can use mountBdForm() from @businessdash/sdk/vanilla.
Headless runtime: createFormController
Use the controller when the form should behave like <BdForm> but your
application owns every rendered element.
import { createFormController } from '@businessdash/sdk/forms'
const controller = createFormController({
client,
slug: 'contact',
submitOptions: {
source: 'marketing-site',
},
onSuccess(result) {
console.log(result.submissionId)
},
})
const unsubscribe = controller.subscribe((snapshot) => {
render(snapshot)
})
await controller.load()
render(controller.snapshot())
The controller is framework-agnostic. A binding reads immutable snapshots and calls controller methods:
controller.setValue(fieldId, value)
controller.next()
controller.back()
controller.goToStep(index)
controller.validate()
await controller.submit()
controller.reset()
controller.destroy()
Snapshot state
controller.snapshot() exposes:
- load status, schema, title, description, and nested field tree,
values,errors, anderrorList,- active step, step count, visible fields, and progress,
canAdvanceandcanSubmit,- upload and submission state,
- the most recent result and success state.
const snapshot = controller.snapshot()
if (snapshot.status === 'ready') {
console.log(snapshot.visibleFields)
console.log(snapshot.values)
console.log(snapshot.progressPercent)
console.log(snapshot.canSubmit)
}
What the controller owns
The controller is the live state machine:
- loads or adopts a
FormSchema, - seeds defaults,
- evaluates condition blocks,
- partitions consecutive steps,
- recomputes calculated fields,
- validates visible/active values,
- defers uploads until submission,
- converts field ids into the published output-key payload,
- submits and exposes a discriminated result.
The UI binding stays deliberately thin.
Prefetch a schema
For SSR/RSC, fetch the schema on the server and hand it to the runtime:
const schema = await client.forms.schema('contact')
const controller = createFormController({
client,
schema,
})
schema avoids the load round trip. A client is still required for submit and
file upload.
Build the UI without the controller
You can use the wire-level APIs directly:
import {
createBdApiClient,
validateFormSubmission,
} from '@businessdash/sdk'
const bd = createBdApiClient({ apiKey, baseUrl })
const schema = await bd.forms.schema('contact')
const data = {
// key values by the stable schema field key
}
const check = validateFormSubmission(schema, data)
if (!check.ok) {
showFieldErrors(check.issues)
} else {
const result = await bd.forms.submit('contact', data, {
source: 'marketing-site',
})
}
Use this low-level path when an existing form library already owns state and validation. The live BusinessDash schema remains the receiving contract.
Test without storing
const result = await bd.forms.submit('contact', data, {
dryRun: true,
})
A dry run validates against the Live server schema and produces the normal result shape without creating a submission.
Submission results
Expected failures resolve as FormSubmitResult; branch on ok and then
reason.
const result = await bd.forms.submit('contact', data)
if (result.ok) {
console.log(result.submissionId)
} else {
console.error(result.reason, result.message, result.issues)
}
| Status | reason | Meaning |
|---|---|---|
200 | — | Accepted; a dry run has no stored id. |
400 / 422 | validation_failed | The Live schema rejected one or more fields. |
400 | invalid_request | Malformed request body. |
401 | unauthorized | Missing, invalid, or expired key. |
403 | forbidden | Missing scope or disallowed origin. |
402 | plan_required | Forms are not included in the plan. |
402 | payment_required | BusinessDash billing is past due. |
402 | service_suspended | Service is suspended after prolonged nonpayment. |
404 | not_found | Missing form or the Live switch is off. |
429 | rate_limited | Too many submissions. |
5xx | server_error | Server-side processing failed. |
0 | client_validation_failed | Local validation stopped the request. |
0 | network_error | The request never reached BusinessDash. |
General Inquiry fallback
The SDK starters use general-inquiry. If the organization has no form with
that slug, the platform-provided General Inquiry schema is used. If the
organization creates its own general-inquiry, that form overrides the
fallback.
Submissions remain scoped to the calling organization.
Choosing the right layer
| Need | Use |
|---|---|
| Render a dashboard-authored form immediately | <BdForm> |
| Custom markup with the same Live form behavior | createFormController() |
| Existing form library, BusinessDash as receiver | forms.schema() + forms.submit() |
| Define database fields, validation metadata, and form structure together in code | createFormSchemaOrchestrator() |
Continue with Unified Form Orchestration or Collections and CLI Changes.