Implement Custom Form Handlers with Instatic Semantic Validation: A Complete Guide

Instatic's TypeBox-driven validation engine can be imported directly from src/core/forms/validation.ts and reused in custom endpoints, allowing you to bypass the default /_instatic/form/submit flow while maintaining the same semantic guarantees.

Instatic provides a fully-featured CMS-native form system that authors use to build semantic HTML forms from primitive nodes. While the default flow relies on built-in endpoints at /_instatic/form/challenge and /_instatic/form/submit, you can implement custom form handlers with Instatic semantic validation to integrate external services or apply custom business logic.

How Instatic's Native Form System Works

The form architecture consists of several tightly coupled components that handle everything from schema definition to runtime submission.

Form primitives such as base.form, base.input, and base.select reside in src/modules/base/forms/ and are registered via src/modules/base/index.ts. At publish time, the editor generates a form snapshot (src/core/forms/snapshot.ts) that captures the structure and bindings.

During runtime, the browser loads a JavaScript module from src/modules/base/forms/formRuntimeJs.ts that injects hidden data-instatic-page-token and data-instatic-page-id fields into each form. By default, submissions route through two endpoints defined in server/forms/handler.ts: POST /_instatic/form/challenge issues an HMAC-signed token, and POST /_instatic/form/submit validates inputs and creates data_rows records.

The core validation logic lives in src/core/forms/validation.ts, which performs type coercion, constraint checking, and global limit enforcement.

When to Use a Custom Handler

Switching to a custom handler bypasses the default endpoints while preserving validation integrity. Consider implementing custom form handlers with Instatic semantic validation when you need:

  • Custom action URLs – Setting the form's mode to custom renders a standard <form action="…" method="…"> tag, directing the browser to post directly to your specified endpoint instead of /_instatic/form/submit.
  • Additional server-side logic – Integrate with external APIs, write audit logs, or apply business rules that extend beyond the default validation scope.
  • Reusable validation – Maintain semantic guarantees by importing Instatic's validation routine into your custom endpoint rather than reimplementing constraint logic from scratch.

Hooking Into Instatic's Validation Engine

The validateFormSubmission Function

The public validator is exported from src/core/forms/validation.ts and accepts the same schema definitions used by the native form editor.

// src/core/forms/validation.ts
export function validateFormSubmission(input: {
  table: DataTable;
  controls: FormControlBinding[];
  values: Record<string, unknown>;
  limits?: FormSubmissionLimits;
}): FormValidationResult { … }

This function coordinates type coercion via coerceFieldValue and constraint validation through validateCoercedValue, returning a structured result that indicates success or detailed field-level errors.

Complete Custom Handler Implementation

Your custom endpoint can import the validator and execute the same checks as the built-in handler before persisting data elsewhere.

import { validateFormSubmission } from '@core/forms/validation';
import { getDataTableById } from '@core/data/repositories';

// Example custom handler (e.g. in server/api/customForm.ts)
export async function handleCustomForm(req: Request) {
  const payload = await req.json(); // { tableId, values }

  // 1️⃣ Load the target DataTable definition (schema)
  const table = await getDataTableById(payload.tableId);
  if (!table) {
    return new Response(JSON.stringify({ error: 'Table not found' }), { status: 404 });
  }

  // 2️⃣ Build the list of form controls (same shape the editor uses)
  const controls = table.fields.map((field) => ({
    fieldId: field.id,
    name: field.name,
    required: field.required,
    // …other UI-specific props can be added here
  }));

  // 3️⃣ Run Instatic's validation
  const result = validateFormSubmission({
    table,
    controls,
    values: payload.values,
  });

  // 4️⃣ Return validation errors or persist the row
  if (!result.ok) {
    return new Response(JSON.stringify({ errors: result.errors }), { status: 400 });
  }

  // Persist the row (example using the data-row repo)
  const rowId = await createDataRow(table.id, result.cells);
  return new Response(JSON.stringify({ rowId }), { status: 201 });
}

What the Validator Checks

The validateFormSubmission function enforces multiple validation layers defined in src/core/forms/validation.ts:

  • Unknown field rejection – Extra fields not defined in the schema are caught early ([validation.ts L45-L54]).
  • Required field enforcement – Missing values for required controls trigger errors ([validation.ts L81-L97]).
  • Type coercion – Values are coerced to their declared types (number, boolean, multi-select, media, relation) via coerceFieldValue ([validation.ts L10-L48]).
  • Constraint validation – String length, min/max limits, regex patterns, email/URL formats, and numeric ranges are verified by validateCoercedValue ([validation.ts L71-L119]).
  • Option whitelisting – Select and multi-select values are validated against allowed option lists ([validation.ts L121-L133]).
  • Global limits – Submission size and field count caps prevent abuse ([validation.ts L27-L33]).

Configuring Custom Mode in the Editor

Authors toggle between CMS-native and custom submission flows through the Form Settings Panel located at src/admin/pages/site/panels/PropertiesPanel/FormSettingsPanel.tsx.

When mode is set to custom, the runtime module in src/modules/base/forms/formRuntimeJs.ts emits a conventional HTML form tag using the action and method attributes you specify, removing the dependency on Instatic's challenge/submit endpoints.

// src/admin/pages/site/panels/PropertiesPanel/FormSettingsPanel.tsx
<FormModeSelector
  value={formNode.props.mode}
  onChange={(mode) => updateNodeProps(nodeId, { mode })}
/>

Implementation Workflow

Follow this sequence to deploy a production-ready custom handler:

  1. Author the form in the visual editor, populate fields, and switch the Mode setting to Custom while specifying your endpoint URL.
  2. Publish the site – The form snapshot retains the full control definitions and DataTable bindings, which your endpoint can reference via the CMS API or embedded JSON.
  3. Handle submission – The browser posts form data directly to your custom URL. Parse the payload and invoke validateFormSubmission with the retrieved schema and posted values.
  4. Process results – Return validation errors in the standard format on failure, or persist the validated result.cells to data_rows or external systems on success.

Key Source Files

Understanding these modules is essential for maintaining parity with Instatic's native behavior:

Summary

  • Import validateFormSubmission from src/core/forms/validation.ts to enforce Instatic's semantic validation in any custom endpoint.
  • Set the form mode to custom via FormSettingsPanel.tsx to generate standard HTML form posts instead of using /_instatic/form/submit.
  • Construct the controls array from your DataTable schema to ensure the validator recognizes all fields and constraints.
  • Handle type coercion, required fields, pattern matching, and global limits automatically by delegating to Instatic's validation engine.
  • Return standardized error objects to the frontend to maintain compatibility with Instatic's error display components.

Frequently Asked Questions

Can I use Instatic validation without the default submit endpoint?

Yes. The validateFormSubmission function exported from src/core/forms/validation.ts is designed to be environment-agnostic. Import it into any server-side handler, pass the DataTable schema and submitted values, and receive the same validation results the native endpoint uses.

What happens to security tokens when using custom mode?

When mode is set to custom, the runtime script in src/modules/base/forms/formRuntimeJs.ts still injects the data-instatic-page-token and data-instatic-page-id hidden fields. Your custom endpoint can optionally verify these tokens against the challenge endpoint, or you may implement your own CSRF protection scheme.

How do I access the form schema in my custom handler?

Retrieve the DataTable definition using getDataTableById from @core/data/repositories as shown in the implementation example. The schema includes field definitions, constraints, and control bindings necessary to construct the validation input.

Does custom mode support rate limiting?

The built-in rate limiting applied at POST /_instatic/form/challenge only governs the default submission flow. Custom endpoints must implement their own rate-limiting middleware or logic, as the browser posts directly to your specified action URL without passing through Instatic's challenge gate.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →