# Build a Form with the Forms SDK



This guide builds a demo-booking form as a page you host: two question steps, a branch that disqualifies small companies, a scheduler step, and a thank-you screen. The form record on Surface is the data model, your HTML is the presentation, and the SDK binds the two ([overview](/docs/forms-sdk/overview)).

## Prerequisites [#prerequisites]

* A Surface environment.
* The SDK installed: `npm install @surface-labs/forms-sdk`.
* Ideally the [Surface MCP server](/docs/mcp-server/setup) connected to your AI assistant; without it, copy IDs from the dashboard.

## Step 1: Create the form record [#step-1-create-the-form-record]

Ask your assistant, or call the tools yourself:

```
create_form with sdk: true
  name: "Demo booking"
  steps:
    1. "About you": IdentityInfo (first name, last name, work email)
    2. "Company": MultipleOptionsQuestion "How large is your team?"
       options: "1-10", "11-50", "51-200", "200+"
    3. "Book a time": CalendlyScreen (your Surface scheduler)
    4. "Thanks" (endStepKind: thank_you): Header "You're booked!"
    5. "Not a fit" (endStepKind: disqualified): Header "Thanks for your interest"
```

* Give every question real question text; the responses table uses it as the column label.
* Ending steps need at least one component; a `Header` is fine.
* Link the scheduler's prefill to the identity step (`linkNameToStep`, `linkEmailToStep`).
* Save the `formId` and `previewToken` from the reply.

The dashboard builds the same record by hand: Forms, Create New Form, Build SDK form.

## Step 2: Get the binding map [#step-2-get-the-binding-map]

```
get_sdk_binding_map formId: <your form>
```

The reply lists every step and question with `questionId`, `componentType`, `fieldNames`, option keys, and `answerShape`, plus `environmentId`, `apiBaseUrl`, and a ready-to-paste `init` snippet. Never retype IDs from memory.

## Step 3: Write the page [#step-3-write-the-page]

```html
<div id="demo-form" hidden>
  <!-- Step 1: identity -->
  <section data-step-id="STEP_ABOUT">
    <div data-question-id="Q_IDENTITY" data-question-type="IdentityInfo">
      <input data-field-name="firstName" placeholder="First name" />
      <input data-field-name="lastName" placeholder="Last name" />
      <input type="email" data-field-name="workEmailAddress" placeholder="Work email" />
    </div>
    <button type="button" class="surface-next-button">Continue</button>
  </section>

  <!-- Step 2: company size, with a branch -->
  <section data-step-id="STEP_COMPANY" hidden>
    <fieldset data-question-id="Q_SIZE" data-question-type="MultipleOptionsQuestion">
      <label><input type="radio" name="size" value="1-10" /> 1-10</label>
      <label><input type="radio" name="size" value="11-50" /> 11-50</label>
      <label><input type="radio" name="size" value="51-200" /> 51-200</label>
      <label><input type="radio" name="size" value="200+" /> 200+</label>
    </fieldset>
    <button type="button" id="company-continue">Continue</button>
  </section>

  <!-- Step 3: scheduler -->
  <section data-step-id="STEP_BOOK" hidden>
    <div id="booking"></div>
  </section>

  <!-- Endings: revealed by the SDK, no buttons needed -->
  <section data-step-id="STEP_THANKS" hidden><h2>You're booked!</h2></section>
  <section data-step-id="STEP_NOT_FIT" hidden><h2>Thanks for your interest</h2></section>
</div>
```

Replace every `STEP_*` and `Q_*` placeholder with real IDs from the binding map, and keep the radio `value` attributes byte-identical to the record's option keys. Note: every container after the first is `hidden`; the choice question binds as a group (no `data-field-name` on options); the scheduler question has no binding; both endings have containers.

## Step 4: Boot, branch, and mount the scheduler [#step-4-boot-branch-and-mount-the-scheduler]

```js
import { SurfaceForms, mountScheduler } from "@surface-labs/forms-sdk";

const form = await SurfaceForms.init({
  environmentId: "ENV_ID",
  formId: "FORM_ID",
  apiBaseUrl: "API_BASE_URL", // from get_sdk_binding_map
  container: document.querySelector("#demo-form"),
  emailValidation: true, // annotate deliverability; never blocks
  preview: true, // remove before deploying
  previewToken: "PREVIEW_TOKEN",
});
document.querySelector("#demo-form").hidden = false;

// The branch: small teams get the disqualified ending.
document.querySelector("#company-continue").addEventListener("click", () => {
  const answer = form.state().answers["STEP_COMPANY"]?.["Q_SIZE"];
  const picked = answer?.choices?.find((c) => c.value)?.key;
  if (!picked) return; // require an answer
  if (picked === "1-10") form.disqualify();
  else form.next();
});

// Mount once, into the still-hidden step container.
mountScheduler({
  form,
  container: document.querySelector("#booking"),
  onEvent(e) {
    if (e.type === "booking_confirmed") form.submit();
  },
});
```

The branch is code-driven, so add `data-surface-nav="js"` to the `<body>`. Everything else (answer capture, partial saves, booking persistence, identity, journey tracking, busy state) is the SDK's job.

## Step 5: Validate the markup [#step-5-validate-the-markup]

```
validate_form_html
  formId: <your form>
  html: <the rendered page>
  containerSelector: "#demo-form"
```

Fetch the page exactly as a visitor receives it; for a framework page, curl the dev server output. Fix every blocker and re-run until clean.

## Step 6: Walk it in preview [#step-6-walk-it-in-preview]

With `preview: true` and the `previewToken`, the page serves the draft record and writes nothing: walk both branches, book a synthetic meeting, confirm both endings render.

## Step 7: Publish and deploy [#step-7-publish-and-deploy]

```
publish_form formId: <your form>
```

Remove `preview: true` and `previewToken`, then deploy.

## Step 8: Verify end to end [#step-8-verify-end-to-end]

Submit one real test response on the deployed page, then read it back:

```
list_responses formId: <your form>
get_response responseId: <the new one>
```

Confirm every answer landed, the choice answer carries the structured list, the booking has `eventScheduled: true`, and the metadata shows `surfaceTagStatus: "sdk"`.

## Where to go next [#where-to-go-next]

<CardGroup cols="2">
  <Card title="Binding contract" icon="list-check" href="/docs/forms-sdk/binding">
    Every attribute and inference rule.
  </Card>

  <Card title="Headless and React" icon="react" href="/docs/forms-sdk/headless-and-react">
    Render the form from your components.
  </Card>

  <Card title="Dos and don'ts" icon="list-check" href="/docs/forms-sdk/dos-and-donts">
    The rules that keep SDK forms correct.
  </Card>

  <Card title="Troubleshooting" icon="wrench" href="/docs/forms-sdk/troubleshooting">
    Symptom to cause to check.
  </Card>
</CardGroup>
