How the Instatic Form Submission System Stores Data in Data Tables
Instatic stores every form submission as a JSON row in the generic data_rows table, linked to a dynamically created table named form_<formId> within the data_tables registry, enabling schema-agnostic persistence across both Postgres and SQLite.
The CoreBunch/Instatic CMS treats structured content—including visitor-submitted form data—as rows in a unified relational model. When you configure a native form using the base.form module, the Instatic form submission system automatically routes submissions through a type-safe API endpoint and persists them using the generic data_tables and data_rows architecture shared by all content types.
Overview of the Data Tables Architecture
Instatic avoids bespoke tables for every feature by employing a generic two-table model defined in server/db/migrations-pg.ts and server/db/migrations-sqlite.ts. Every piece of structured data, including form submissions, lives in data_rows linked to a metadata entry in data_tables.
data_tables– Stores table metadata (name, schema version)data_rows– Stores actual content as JSON, linked viatable_id
This design allows the form module to create new storage buckets on-the-fly without DDL migrations when fields change.
The Form Submission Flow
The end-to-end flow spans client-side collection, server-side validation, and repository-based persistence.
1. Form Definition and Client Setup
In src/modules/base/forms/index.ts, the form module declares a component that renders a <form> element with data-instatic-form-id and data-instatic-form-mode attributes based on the author’s configuration.
// Example configuration
{
id: 'base.form',
props: {
formId: 'contact',
mode: 'cms' // Uses native CMS storage
}
}
Authors set a Form ID (formId) which determines the target table name at runtime.
2. Client-Side Data Collection
When a visitor submits the form, src/modules/base/forms/formRuntimeJs.ts intercepts the event, serializes the inputs using new FormData(event.target), and POSTs a JSON payload to /api/forms/submit.
// Payload structure sent to server
{
"formId": "contact",
"values": {
"name": "Alice",
"email": "alice@example.com",
"message": "Hello!"
}
}
The runtime uses the @core/http utility apiRequest to ensure consistent headers and error handling.
3. Server-Side Routing and Validation
The entry point in server/router.ts matches the /api/forms/submit path and forwards the request to the form-submission handler. Before any database operation, the handler validates the request body against FormSubmissionSchema using TypeBox (as demonstrated in src/core/utils/validation.ts).
If validation fails, the system returns structured errors via src/core/utils/errorMessage.ts without touching the database.
4. Database Insertion via Repository
The actual storage logic resides in server/repositories/site.ts inside the handleFormSubmission and createFormSubmissionRow functions. The repository:
- Generates the table name by prefixing the
formIdwithform_(e.g.,form_contact) - Creates the table entry in
data_tablesif it does not exist - Inserts the submission as a new row in
data_rowswith the JSON payload
-- On-first-submit table creation
INSERT INTO data_tables (name) VALUES ('form_contact');
-- Submission storage
INSERT INTO data_rows (table_id, json) VALUES (
(SELECT id FROM data_tables WHERE name = 'form_contact'),
'{"name":"Alice","email":"alice@example.com","message":"Hello!"}'
);
Because the repository uses dialect-neutral SQL via the unified DB adapters, this flow works identically for both Postgres and SQLite deployments.
Database Schema Implementation
The underlying schema is defined in server/db/migrations-pg.ts (Postgres) and server/db/migrations-sqlite.ts (SQLite). Both define data_tables and data_rows with the following characteristics:
- Schema-agnostic fields – Form fields are stored as JSON, so adding or removing inputs does not require schema migrations
- Indexed lookups – The
table_idforeign key enables efficient queries filtered by form ID - Unified types – The same TypeScript types drive both the validation layer (TypeBox) and the repository layer, ensuring end-to-end type safety
Practical Implementation Example
To store submissions from a contact form, configure the module and let the runtime handle persistence:
// Visual editor configuration (src/modules/base/forms usage)
{
id: 'base.form',
props: {
formId: 'newsletter_signup',
mode: 'cms'
}
}
After a visitor submits, the resulting database state includes:
-- Query to retrieve submissions
SELECT dr.json, dr.created_at
FROM data_rows dr
JOIN data_tables dt ON dr.table_id = dt.id
WHERE dt.name = 'form_newsletter_signup';
Each submission appends a new row to form_newsletter_signup, accessible via the same API used for pages, products, or any other CMS content.
Summary
- Instatic uses a generic relational model where
data_tablesregisters form-specific buckets anddata_rowsstores the actual JSON submissions. - Table names are dynamic, following the pattern
form_<formId>, created automatically on the first submission without manual migrations. - Type-safe boundaries ensure every submission is validated via TypeBox schemas before reaching
server/repositories/site.ts. - Cross-database compatibility is achieved through dialect-neutral repository patterns that support both Postgres and SQLite.
- Schema flexibility allows form fields to change without database alterations, as values are stored as JSON documents.
Frequently Asked Questions
What database tables does Instatic create for form submissions?
Instatic creates entries in the generic data_tables registry with names like form_contact or form_newsletter, paired with corresponding rows in data_rows. It does not create dedicated physical tables for each form; instead, it uses the unified data_tables/data_rows schema defined in server/db/migrations-pg.ts and server/db/migrations-sqlite.ts.
How does Instatic handle form validation before storage?
The system validates all incoming payloads against FormSubmissionSchema using TypeBox at the boundary layer in server/repositories/site.ts. Validation errors are processed through src/core/utils/errorMessage.ts and returned to the client before any database write occurs, ensuring only well-formed data reaches the storage layer.
Can form submissions be queried like other CMS content?
Yes. Because submissions live in the same data_tables/data_rows architecture as pages and custom content types, you can query them via the internal API, expose them through the admin UI, export CSV files, or join them with other tables in custom plugins using standard SQL against the form_<formId> table identifier.
Does adding new form fields require database migrations?
No. Form fields are stored as JSON in the data_rows table, making the storage schema-agnostic. Adding or removing fields in the form configuration only changes the JSON structure written to new rows; existing rows remain untouched and valid, eliminating the need for DDL migrations when modifying forms.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →