Forms Runtime

There are two form concepts in @businessdash/sdk 0.9.50. They solve different parts of the problem:

ConceptSourceOutput
Unified orchestrationLocal collection() + bd + layout containersA database schema and immutable uiFormTree definition
Published forms runtimeA Live form loaded from BusinessDashLive 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, and errorList,
  • active step, step count, visible fields, and progress,
  • canAdvance and canSubmit,
  • 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:

  1. loads or adopts a FormSchema,
  2. seeds defaults,
  3. evaluates condition blocks,
  4. partitions consecutive steps,
  5. recomputes calculated fields,
  6. validates visible/active values,
  7. defers uploads until submission,
  8. converts field ids into the published output-key payload,
  9. 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)
}
StatusreasonMeaning
200—Accepted; a dry run has no stored id.
400 / 422validation_failedThe Live schema rejected one or more fields.
400invalid_requestMalformed request body.
401unauthorizedMissing, invalid, or expired key.
403forbiddenMissing scope or disallowed origin.
402plan_requiredForms are not included in the plan.
402payment_requiredBusinessDash billing is past due.
402service_suspendedService is suspended after prolonged nonpayment.
404not_foundMissing form or the Live switch is off.
429rate_limitedToo many submissions.
5xxserver_errorServer-side processing failed.
0client_validation_failedLocal validation stopped the request.
0network_errorThe 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

NeedUse
Render a dashboard-authored form immediately<BdForm>
Custom markup with the same Live form behaviorcreateFormController()
Existing form library, BusinessDash as receiverforms.schema() + forms.submit()
Define database fields, validation metadata, and form structure together in codecreateFormSchemaOrchestrator()

Continue with Unified Form Orchestration or Collections and CLI Changes.