BETTER-STACK

Form Builder Plugin

Visual drag-and-drop form builder with JSON Schema storage and public form rendering

Form Builder Plugin Demo - Forms ListForm Builder Plugin Demo - BuilderForm Builder Plugin Demo - PreviewForm Builder Plugin Demo - Submissions

The Form Builder plugin provides a visual drag-and-drop form creation interface where administrators can create forms that are serialized and stored as JSON Schema. This is distinct from the CMS plugin - while CMS uses developer-defined Zod schemas, Form Builder allows non-technical administrators to create forms dynamically.

Key Features:

  • Visual Form Builder - Drag-and-drop interface for creating forms with various field types
  • JSON Schema Storage - Forms are serialized to JSON Schema for database persistence
  • Public Form Rendering - Render forms by slug on the frontend with automatic validation
  • Submission Tracking - Store and view form submissions with IP address and user agent logging
  • Backend Hooks - Lifecycle hooks for authentication, rate limiting, and integrations

Installation

Ensure you followed the general framework installation guide first.

1. Add Plugin to Backend API

Register the Form Builder backend plugin:

lib/better-stack.ts
import { betterStack } from "@btst/stack"
import { formBuilderBackendPlugin } from "@btst/stack/plugins/form-builder/api"

const { handler, dbSchema } = betterStack({
  basePath: "/api/data",
  plugins: {
    formBuilder: formBuilderBackendPlugin({
      hooks: {
        // Authentication - check if user can access admin pages
        onBeforeListForms: async (ctx) => {
          const session = await getSession(ctx.headers)
          return session?.user?.isAdmin === true
        },
        onBeforeFormCreated: async (data, ctx) => {
          const session = await getSession(ctx.headers)
          if (!session?.user?.isAdmin) return false
          return data
        },
        // Rate limiting for public submissions
        onBeforeSubmission: async (formSlug, data, ctx) => {
          // Check rate limit by IP
          const isAllowed = await checkRateLimit(ctx.ipAddress, formSlug)
          if (!isAllowed) return false
          return data
        },
        // Post-submission actions
        onAfterSubmission: async (submission, form, ctx) => {
          // Send notification email
          await sendEmail({
            to: "admin@example.com",
            subject: `New submission: ${form.name}`,
            body: JSON.stringify(JSON.parse(submission.data), null, 2),
          })
          // CRM integration
          await updateCRM(submission.data)
        },
      },
    })
  },
  adapter: (db) => createMemoryAdapter(db)({})
})

export { handler, dbSchema }

2. Add Plugin to Client

Register the Form Builder client plugin:

lib/better-stack-client.tsx
import { createStackClient } from "@btst/stack/client"
import { formBuilderClientPlugin } from "@btst/stack/plugins/form-builder/client"
import { QueryClient } from "@tanstack/react-query"

const getBaseURL = () => 
  process.env.BASE_URL || "http://localhost:3000"

export const getStackClient = (queryClient: QueryClient, options?: { headers?: Headers }) => {
  const baseURL = getBaseURL()
  return createStackClient({
    plugins: {
      "form-builder": formBuilderClientPlugin({
        apiBaseURL: baseURL,
        apiBasePath: "/api/data",
        siteBaseURL: baseURL,
        siteBasePath: "/pages",
        queryClient: queryClient,
        headers: options?.headers,
        hooks: {
          beforeLoadFormList: async (context) => {
            const session = await getSession(context.headers)
            return session?.user?.isAdmin === true
          },
          beforeLoadFormBuilder: async (formId, context) => {
            const session = await getSession(context.headers)
            return session?.user?.isAdmin === true
          },
          beforeLoadSubmissions: async (formId, context) => {
            const session = await getSession(context.headers)
            return session?.user?.isAdmin === true
          },
          onLoadError: (error, context) => {
            redirect("/auth/sign-in")
          },
        },
      })
    }
  })
}

3. Configure Provider Overrides

Add Form Builder overrides to your layout:

app/pages/layout.tsx
import type { FormBuilderPluginOverrides } from "@btst/stack/plugins/form-builder/client"

type PluginOverrides = {
  "form-builder": FormBuilderPluginOverrides,
}

<BetterStackProvider<PluginOverrides>
  basePath="/pages"
  overrides={{
    "form-builder": {
      apiBaseURL: baseURL,
      apiBasePath: "/api/data",
      navigate: (path) => router.push(path),
      refresh: () => router.refresh(),
      Link: ({ href, ...props }) => <Link href={href || "#"} {...props} />,
      // Optional file upload for file fields
      uploadFile: async (file) => {
        // Your file upload logic
        return "https://example.com/file.pdf"
      },
      // Lifecycle hooks
      onRouteRender: async (routeName, context) => {
        console.log(`Form Builder route:`, routeName)
      },
      onRouteError: async (routeName, error, context) => {
        console.error(`Form Builder error:`, routeName, error.message)
      },
    }
  }}
>
  {children}
</BetterStackProvider>

4. Import CSS

Add the Form Builder styles to your global CSS:

app/globals.css
@import "@btst/stack/plugins/form-builder/css";

Admin Routes

The Form Builder plugin provides these admin routes:

RouteDescription
/formsList all forms with create, edit, delete actions
/forms/newCreate a new form with the visual form builder
/forms/:id/editEdit an existing form
/forms/:id/submissionsView submissions for a form

Admin routes are automatically set to noindex for SEO. Don't include them in your public sitemap.

Form Builder UI

The form builder provides a drag-and-drop interface with:

  • Component Palette - Available field types to drag onto the canvas
  • Canvas - Where you build your form by arranging fields
  • Preview Tab - Live preview of how the form will look
  • JSON Schema Tab - View the generated JSON Schema

Available Field Types

Field TypeDescriptionJSON Schema Properties
Text InputSingle-line text fieldtype: "string"
EmailEmail input with validationtype: "string", format: "email"
PasswordPassword inputtype: "string", fieldType: "password"
NumberNumeric inputtype: "number" with minimum/maximum
Text AreaMulti-line text fieldtype: "string", fieldType: "textarea"
SelectDropdown selectiontype: "string", enum: [...]
CheckboxBoolean checkboxtype: "boolean"
SwitchToggle switchtype: "boolean", fieldType: "switch"
Radio GroupRadio button grouptype: "string", enum: [...], fieldType: "radio"
Date PickerDate selectiontype: "string", format: "date-time"
PhonePhone number inputtype: "string", fieldType: "phone"
URLWebsite URL inputtype: "string", format: "uri"

Field Properties

Each field can be configured with:

PropertyDescription
LabelDisplay label for the field
Field NameThe property key in the JSON Schema
DescriptionHelp text shown below the field
PlaceholderPlaceholder text in the input
RequiredWhether the field is required
Min/MaxMinimum and maximum values (numbers) or length (strings)
OptionsFor select, radio, and checkbox groups
Default ValuePre-filled value for the field

Public Form Rendering

The FormRenderer component allows you to render forms on public pages by their slug:

app/form-demo/[slug]/page.tsx
"use client"

import { FormRenderer } from "@btst/stack/plugins/form-builder/client/components"

export default function FormDemoPage({ params }: { params: { slug: string } }) {
  return (
    <div className="max-w-2xl mx-auto p-6">
      <FormRenderer
        slug={params.slug}
        onSuccess={(submission) => {
          console.log("Form submitted:", submission)
          // submission.form contains successMessage and redirectUrl
        }}
        onError={(error) => {
          console.error("Submission error:", error)
        }}
        // Optional: Custom loading/error states
        LoadingComponent={() => <div>Loading form...</div>}
        ErrorComponent={({ error }) => (
          <div>Form not found: {error.message}</div>
        )}
        // Optional: Custom submit button text
        submitButtonText="Send Message"
        // Optional: Custom success message (overrides form's successMessage)
        successMessage="Thanks for your submission!"
        className="space-y-6"
      />
    </div>
  )
}

FormRenderer Props

PropTypeDescription
slugstringForm slug to fetch and render
onSuccess(submission) => voidCallback after successful submission (submission.form has success info)
onError(error) => voidCallback when submission fails
LoadingComponentComponentTypeCustom loading state
ErrorComponentComponentType<{ error: Error }>Custom error state
submitButtonTextstringCustom submit button text
successMessagestringOverride the form's success message
fieldComponentsRecord<string, ComponentType>Custom field components
classNamestringAdditional CSS classes

The FormRenderer uses SteppedAutoForm internally, which automatically handles both single-step and multi-step forms based on the JSON Schema structure.

Client Hooks

Access form data in your frontend using the provided hooks:

Available Hooks

HookDescriptionReturns
useFormsAdmin()List all forms (admin){ forms, total, isLoading, error, refetch }
useFormBySlug(slug)Get form by slug (public){ form, isLoading, error }
useSuspenseFormById(id)Get form by ID with Suspense{ form, refetch }
useCreateForm()Create mutationReact Query mutation
useUpdateForm()Update mutationReact Query mutation
useDeleteForm()Delete mutationReact Query mutation
useSubmitForm(slug)Submit form dataReact Query mutation
useSubmissions(formId)List submissions for a form{ submissions, total, isLoading }
useDeleteSubmission(formId)Delete submissionReact Query mutation

Usage Examples

import { 
  useFormBySlug,
  useSubmitForm,
  useFormsAdmin 
} from "@btst/stack/plugins/form-builder/client/hooks"

// Public: Fetch form by slug
function ContactPage() {
  const { form, isLoading } = useFormBySlug("contact-form")
  const submitForm = useSubmitForm("contact-form")

  if (isLoading || !form) return <Loading />

  return (
    <AutoForm
      schema={JSON.parse(form.schema)}
      onSubmit={async (data) => {
        await submitForm.mutateAsync({ data })
      }}
    />
  )
}

// Admin: List all forms
function FormsAdmin() {
  const { forms, total, isLoading } = useFormsAdmin()
  
  return (
    <ul>
      {forms.map(form => (
        <li key={form.id}>{form.name} - {form.status}</li>
      ))}
    </ul>
  )
}

Backend Hooks

Customize Form Builder behavior with backend lifecycle hooks:

FormBuilderBackendHooks

formBuilderBackendPlugin({
  hooks: {
    // Form CRUD authorization
    onBeforeListForms: async (ctx) => {
      // Return true to allow, false to deny
      return isAdmin(ctx.headers)
    },
    onBeforeFormCreated: async (data, ctx) => {
      // Return false to deny, or modified data to allow
      return data
    },
    onBeforeFormUpdated: async (formId, data, ctx) => {
      return data
    },
    onBeforeFormDeleted: async (formId, ctx) => {
      return true
    },
    
    // Form lifecycle
    onAfterFormCreated: async (form, ctx) => {
      console.log("Form created:", form.name)
    },
    onAfterFormUpdated: async (form, ctx) => {
      console.log("Form updated:", form.name)
    },
    onAfterFormDeleted: async (formId, ctx) => {
      console.log("Form deleted:", formId)
    },
    
    // Submission authorization and processing
    onBeforeSubmission: async (formSlug, data, ctx) => {
      // Access IP and user agent for rate limiting
      console.log("Submission from:", ctx.ipAddress, ctx.userAgent)
      
      // Rate limiting example
      const allowed = await checkRateLimit(ctx.ipAddress, formSlug)
      if (!allowed) return false
      
      // Spam filtering
      if (containsSpam(data)) return false
      
      return data
    },
    onAfterSubmission: async (submission, form, ctx) => {
      // Send email notifications
      await sendEmail({
        to: "admin@example.com",
        subject: `New ${form.name} submission`,
        body: formatSubmission(submission.data),
      })
      
      // CRM integration
      await pushToCRM(submission, form)
      
      // Webhook
      await triggerWebhook(form.webhookUrl, submission)
    },
    
    // Submissions list authorization
    onBeforeListSubmissions: async (formId, ctx) => {
      return isAdmin(ctx.headers)
    },
    onBeforeDeleteSubmission: async (submissionId, ctx) => {
      return isAdmin(ctx.headers)
    },
    
    // Error handling
    onFormError: async (error, operation, ctx) => {
      console.error(`Form ${operation} error:`, error.message)
    },
    onSubmissionError: async (error, formSlug, ctx) => {
      console.error(`Submission to ${formSlug} error:`, error.message)
    },
  },
})

Hook Context

All hooks receive a context object with:

PropertyTypeDescription
headersHeadersRequest headers for auth
userIdstring | undefinedAuthenticated user ID if available
ipAddressstring | undefinedClient IP address
userAgentstring | undefinedClient user agent

Client Hooks (SSR Authorization)

Use hooks in the client plugin config for async authorization during SSR:

lib/better-stack-client.tsx
import { redirect } from "next/navigation"

"form-builder": formBuilderClientPlugin({
  // ... config
  hooks: {
    beforeLoadFormList: async (context) => {
      const session = await getSession(context.headers)
      return session?.user?.isAdmin === true
    },
    beforeLoadFormBuilder: async (formId, context) => {
      // formId is undefined for new forms
      const session = await getSession(context.headers)
      return session?.user?.isAdmin === true
    },
    beforeLoadSubmissions: async (formId, context) => {
      const session = await getSession(context.headers)
      return session?.user?.isAdmin === true
    },
    afterLoadFormList: async (forms, context) => {
      console.log("Loaded", forms?.length, "forms")
      return true
    },
    onLoadError: (error, context) => {
      // Redirect on authorization failure
      redirect("/auth/sign-in")
    },
  },
})

Use client hooks for SSR. These hooks run during server-side data loading and support async operations like session checks. The onLoadError hook is called when any beforeLoad* hook returns false, allowing you to redirect unauthorized users.

API Endpoints

The Form Builder plugin exposes these REST endpoints:

EndpointMethodDescription
/form-builder/formsGETList all forms (query: limit, offset, status)
/form-builder/formsPOSTCreate a new form
/form-builder/forms/:slugGETGet form by slug
/form-builder/forms/:idPUTUpdate a form
/form-builder/forms/:idDELETEDelete a form
/form-builder/forms/:slug/submitPOSTSubmit form data
/form-builder/forms/:id/submissionsGETList submissions for a form
/form-builder/submissions/:idDELETEDelete a submission

Form Schema Structure

Forms are stored with this schema:

interface Form {
  id: string
  name: string          // Display name
  slug: string          // URL-friendly identifier
  schema: string        // JSON Schema as string
  successMessage?: string  // Message shown after submission
  redirectUrl?: string     // URL to redirect after submission
  status: "draft" | "published"
  createdAt: Date
  updatedAt: Date
}

interface FormSubmission {
  id: string
  formId: string        // Reference to form
  data: string          // Submitted data as JSON string
  ipAddress?: string    // Client IP
  userAgent?: string    // Client user agent
  createdAt: Date
}

Multi-Step Forms

The Form Builder supports multi-step forms through JSON Schema's allOf structure:

{
  "type": "object",
  "allOf": [
    {
      "title": "Step 1: Personal Info",
      "properties": {
        "name": { "type": "string", "label": "Full Name" },
        "email": { "type": "string", "format": "email" }
      }
    },
    {
      "title": "Step 2: Details",
      "properties": {
        "company": { "type": "string" },
        "message": { "type": "string", "fieldType": "textarea" }
      }
    }
  ]
}

The SteppedAutoForm component automatically renders this as a multi-step wizard with navigation.

Custom Field Components

Provide custom field components via the fieldComponents prop on FormRenderer:

import type { AutoFormInputComponentProps } from "@btst/stack/plugins/form-builder/client"

function CustomRating({ field, label }: AutoFormInputComponentProps) {
  return (
    <div>
      <label>{label}</label>
      <StarRating 
        value={field.value} 
        onChange={field.onChange} 
      />
    </div>
  )
}

<FormRenderer
  slug="feedback"
  fieldComponents={{
    rating: CustomRating,
    richText: MyRichTextEditor,
  }}
/>

API Reference

Backend (@btst/stack/plugins/form-builder/api)

formBuilderBackendPlugin

Creates the backend plugin with optional hooks configuration.

FormBuilderBackendHooks

HookParametersReturnDescription
onBeforeListFormsctxbooleanAuthorization for listing forms
onBeforeFormCreateddata, ctxdata | falseValidate/transform before create
onAfterFormCreatedform, ctxvoidPost-create lifecycle
onBeforeFormUpdatedid, data, ctxdata | falseValidate/transform before update
onAfterFormUpdatedform, ctxvoidPost-update lifecycle
onBeforeFormDeletedid, ctxbooleanAuthorization for delete
onAfterFormDeletedid, ctxvoidPost-delete lifecycle
onBeforeSubmissionslug, data, ctxdata | falseValidate/rate limit submissions
onAfterSubmissionsubmission, form, ctxvoidPost-submission actions
onBeforeListSubmissionsformId, ctxbooleanAuthorization for listing
onBeforeDeleteSubmissionid, ctxbooleanAuthorization for delete
onFormErrorerror, operation, ctxvoidHandle form operation errors
onSubmissionErrorerror, slug, ctxvoidHandle submission errors

Client (@btst/stack/plugins/form-builder/client)

formBuilderClientPlugin

Creates the client plugin with routes and SSR loaders.

FormBuilderPluginOverrides

PropertyTypeRequiredDescription
apiBaseURLstringYesBase URL for API requests
apiBasePathstringYesAPI path prefix
navigate(path: string) => voidYesNavigation function
LinkComponentTypeNoLink component
refresh() => voidNoRefresh function
uploadFile(file: File) => Promise<string>NoFile upload handler
fieldComponentsRecord<string, ComponentType>NoCustom field components
localizationFormBuilderLocalizationNoCustom labels
showAttributionbooleanNoShow Better Stack attribution
onRouteRender(route, context) => voidNoLifecycle hook
onRouteError(route, error, context) => voidNoError hook

FormBuilderClientHooks

HookParametersReturnDescription
beforeLoadFormListcontextboolean | Promise<boolean>SSR auth for forms list
afterLoadFormListforms, contextbooleanPost-load hook
beforeLoadFormBuilderformId, contextboolean | Promise<boolean>SSR auth for builder
afterLoadFormBuilderform, contextbooleanPost-load hook
beforeLoadSubmissionsformId, contextboolean | Promise<boolean>SSR auth for submissions
afterLoadSubmissionssubmissions, contextbooleanPost-load hook
onLoadErrorerror, contextvoidHandle auth failures

Schema Converter Utilities (@btst/stack/plugins/form-builder/client)

The Form Builder plugin re-exports schema converter utilities for converting between Zod schemas and JSON Schema. These are useful when working with form schemas programmatically:

zodToFormSchema

Convert a Zod schema to JSON Schema with proper handling for dates, steps metadata, and date constraints:

Prop

Type

Example:

import { zodToFormSchema } from "@btst/stack/plugins/form-builder/client"

const jsonSchema = zodToFormSchema(ContactFormSchema, {
  steps: [
    { id: "personal", title: "Personal Information" },
    { id: "message", title: "Your Message" }
  ],
  stepGroupMap: {
    name: 0,
    email: 0,
    message: 1
  }
})

formSchemaToZod

Convert JSON Schema back to a Zod schema with proper handling for date fields, constraints, and steps metadata. This is used internally by FormRenderer to validate form submissions:

Prop

Type

Example:

import { formSchemaToZod } from "@btst/stack/plugins/form-builder/client"

// Convert JSON Schema from database to Zod for validation
const zodSchema = formSchemaToZod(jsonSchema)
const result = zodSchema.safeParse(submissionData)

Utility Functions

Prop

Type

Prop

Type

Prop

Type

Types

Prop

Type

Prop

Type