Open Form

Form

The root Form component and the form file contract: one file per form, render-to-collect, props, themes, Submit and the Success screen.

<Form> is the root of every form. A form is one file — forms/<id>/index.tsx — whose default export renders exactly one <Form>. The folder name is the form's id and its public URL: /f/<id>.

forms/meetup/index.tsx
import { defineForm, Email, Form, Page, Select, Submit, Text, When } from '@open-form-build/core';

export const config = defineForm({
  title: 'Developer Meetup Registration',
  submissions: { maxResponses: 100, uniqueBy: 'email' },
});

export default function MeetupForm() {
  return (
    <Form>
      <Page title="About you">
        <Text name="name" label="Name" required autoComplete="name" />
        <Email name="email" label="Email" required />
      </Page>
      <Page title="Background">
        <Select name="role" label="Role" options={['Student', 'Engineer', 'Designer']} required />
        <When field="role" equals="Student">
          <Text name="school" label="School" required />
        </When>
      </Page>
      <Submit>Register</Submit>
    </Form>
  );
}

File contract

  • forms/<id>/index.tsx is required. Use a kebab-case id. Next to it you can keep a stylesheet, locales/*.json, the form's own theme/, and local components or option lists for the parts that would crowd index.tsx.
  • index.tsx shows the whole form at a glance: config, every <Page> and <When>, and every field's name and label live there. A local component gets its name and label as props instead of declaring a field of its own.
  • Keep the folder flat: other files sit next to index.tsx, and theme/ and locales/ are the only subfolders. The form-authoring skill has agents follow the same layout.
  • export const config = defineForm({...}) holds the title, description and everything the server enforces: submission limits, dates, protection, the success screen. See defineForm.
  • The default export is a component that renders one <Form>. Inside it, write any React you like — your own components, .map(), wrappers — as long as the inputs are field components or useField.
  • Don't name the default export like an Open Form component — Form, Email, Text — or a global like Date or Number. A component's name would make the form render itself, so open-form check, dev and build refuse it and name the file. A global's name isn't caught, but it shadows the real Date or Number in that file. open-form create adds Form to the name (meetup becomes MeetupForm) so this can't happen.
  • config.title and config.description render as the form's header. Don't repeat them in the JSX.

Render-to-collect

The server never parses your file. It renders the default export once in a collect mode: every <Page> renders, every <When> renders its children and records its condition, and every field registers its name, type, constraints, step and conditions. The result is the form's schema — what the server validates against, what versions are recorded under, and what Responses and exports use as columns. Custom validate functions are collected too, so the server runs them.

Because of that:

  • Don't touch window, document or localStorage during render. Effects are fine.
  • Keep field names stable. Never derive them from Date.now() or random values. __proto__ isn't allowed as a name.
  • A name may appear twice only with the same field type, in <When> branches that don't show together — for example a postal code with one validate per country. Each copy's validate applies while that copy is showing. Two types under one name, or two copies under the same conditions (say, on two pages), is a load error.
  • The browser repeats this render once when the form loads, off-screen, so it decides what's showing exactly as the server will. Effects in your own components run (and clean up) once more because of it.
  • A default export that renders no <Form>, or more than one, fails to load with an error that names the file.

Props

Prop

Type

aside is for live previews, summaries and prices — see Hooks. components is covered in Layouts.

Leave onSubmit out in an Open Form workspace. Submission, server validation, protection and storage are built in. onSubmit receives the answers the server would store — fields that are showing, trimmed and typed — and skips all of that.

Theme

A form owns its look, in forms/<id>/theme/: index.ts with its tokens, index.tsx once it adds components. open-form create <id> writes it and the import, from a theme's tokens with --theme <theme>. Pass it to theme:

forms/meetup/index.tsx
import theme from './theme/index.ts'; // above the form's own CSS, so the form's CSS wins
import './meetup.css';

<Form theme={theme}>…</Form>

The theme's tokens become the form's --of-* variables, and its components replace the defaults: defaultComponents ← the theme's components ← <Form components>. The root element gets data-of-theme="<id>", so the theme's CSS is scoped to this form. Without theme, the form uses the base styles, which are the classic starter's look — or, under a <FormHost theme>, that fallback theme. To adjust the default look, give the form a theme/index.ts with defineTheme({}) and change what you need, or press Create look file under Adjust in Studio's preview. Editing it changes only this form; themes in themes/ are references and never change it. See Themes and defineTheme.

For a form-specific tweak on top of the theme, import a local stylesheet after it and scope it with className. Prefix every class and build on the --of-* tokens so the theme and dark mode keep working:

forms/pass/index.tsx
import theme from './theme/index.ts';
import './pass.css';

<Form theme={theme} className="pass-root">…</Form>

<Submit>

Sets the label of the final button. It renders nothing itself; Back and Next are automatic. Without it the button reads "Submit" in the language the form is shown in.

<Submit>Register</Submit>

Put it directly inside <Form>. One placed inside the last <Page> is found too.

When Next or Submit finds unanswered or invalid fields, the form scrolls the first one on the page into the middle of the screen and focuses it, with its label and error in view. A field the server rejects is brought back the same way, on its own step. The scroll is instant for visitors who prefer reduced motion.

<Success>

Replaces the thank-you screen shown after a successful submission.

<Success title="You're in">
  <p>We'll email your ticket before the event.</p>
</Success>

The title comes from <Success title>, else config.success.title, else a built-in sentence. The body comes from its children, else config.success.message, else a built-in sentence. Children can read answers with useFieldValue to personalise the screen. Set config.success.redirect to send respondents to another page after they submit.

<AddToCalendar>

A Google Calendar link and an Apple / Outlook (.ics) download for one event. For a fixed event, set config.success.calendar and the thank-you screen shows it without any code. Render the component yourself when the event depends on the answers:

function Session() {
  const day = useFieldValue<string>('day');
  return (
    <AddToCalendar
      title="Pottery workshop"
      start={day === 'sat' ? '2026-11-14T14:00' : '2026-11-15T14:00'}
      end={day === 'sat' ? '2026-11-14T16:00' : '2026-11-15T16:00'}
      timeZone="Asia/Taipei"
      location="Studio 3F"
    />
  );
}

<Success title="See you at the wheel">
  <Session />
</Success>

It takes the same props as success.calendar, and renders nothing when the dates can't be read.

On this page