Database Schema Structure in Instatic: How data_tables and data_rows Store All Content
Instatic replaces traditional CMS tables with a unified schema built on two core tables—data_tables for collection definitions and data_rows for content instances—enabling flexible storage of posts, pages, components, and custom data via JSON-structured cells.
The CoreBunch/Instatic repository implements a polymorphic database schema structure that eliminates the need for separate tables for posts, pages, and components. Instead, every content type is defined as a record in data_tables, while all actual content lives as JSON cells in data_rows. This design allows the CMS to handle arbitrary content structures without schema migrations when adding new fields.
The Unified Data Model
Instatic's schema centers on two complementary tables that separate structure from content.
The data_tables Definition Table
The data_tables table stores metadata about every collection in the system, whether built-in or user-defined. According to src/core/data/schemas.ts, each table record contains identifying fields, routing configuration, and a JSON array defining all available fields.
Key columns include:
id,name,slug– Primary identifiers and human-readable labelskind– Discriminator indicating whether the table represents a post type, generic data collection, page, component, or layout snapshotsingularLabel,pluralLabel– Display names for the UIrouteBase– URL prefix for frontend routingprimaryFieldId– The default field used for titles or linksfields– JSON array ofDataFieldobjects describing every column/field in the collectionsystem– Boolean flag distinguishing built-in tables from user-created ones- Audit columns:
createdByUserId,updatedByUserId,createdAt,updatedAt
The data_rows Instance Table
The data_rows table holds the actual content instances. Each row references its parent table via tableId and stores cell values as a JSON map in the cells column.
Key columns include:
id,tableId– Primary key and foreign key todata_tables.idcells– JSON object mappingfieldIdto values (text, numbers, media references, etc.)slug,status– URL-friendly identifier and publication stateauthorUserId– Reference to the content author- Audit JSON columns:
author,createdBy,updatedBy,publishedBy(denormalized user snapshots) - Temporal columns:
createdAt,updatedAt,publishedAt,scheduledPublishAt deletedAt– Nullable timestamp enabling soft deletion
Schema Implementation in TypeBox
The source of truth for the database schema structure resides in src/core/data/schemas.ts, where TypeBox schemas define valid shapes for tables, rows, and fields. This approach ensures runtime validation matches the database constraints.
Table Kinds
The DataTableKindSchema union restricts the kind column to five specific literals:
// src/core/data/schemas.ts
export const DataTableKindSchema = Type.Union([
Type.Literal('postType'), // CMS-managed content types with built-in fields
Type.Literal('data'), // Arbitrary user-defined collections
Type.Literal('page'), // Editor-managed pages
Type.Literal('component'), // Editor-managed visual components
Type.Literal('layout'), // Saved layout snapshots
])
Row Statuses
Content lifecycle states are enforced via DataRowStatusSchema:
// src/core/data/schemas.ts
export const DataRowStatusSchema = Type.Union([
Type.Literal('draft'),
Type.Literal('published'),
Type.Literal('unpublished'),
Type.Literal('scheduled'), // Published by the scheduler later
])
Field Definitions
Each field in a table's fields array follows the DataFieldSchema discriminated union, capturing type-specific configuration for text, number, media, relation, pageTree, and other field types. The runtime constant DATA_FIELD_TYPES enumerates all supported literals for iteration and validation.
Database Migrations
The concrete SQL schema is established through migration files for SQLite (server/db/migrations-sqlite.ts) and PostgreSQL (server/db/migrations-pg.ts). Both enforce identical structures with appropriate foreign key constraints.
The data_tables creation SQL includes a check constraint on the kind column:
-- server/db/migrations-sqlite.ts
create table if not exists data_tables (
id text primary key,
name text not null,
slug text not null unique,
kind text not null check (kind in ('postType','data','page','component','layout')),
singular_label text not null,
plural_label text not null,
route_base text not null,
primary_field_id text not null,
fields_json text not null, -- JSON array of DataField objects
system integer not null default 0,
created_by_user_id text,
updated_by_user_id text,
created_at text not null,
updated_at text not null
);
The data_rows table enforces referential integrity and status constraints:
-- server/db/migrations-sqlite.ts
create table if not exists data_rows (
id text primary key,
table_id text not null references data_tables(id) on delete restrict,
cells_json text not null, -- JSON map of fieldId → value
slug text not null,
status text not null check (status in ('draft','published','unpublished','scheduled')),
author_user_id text,
created_by_user_id text,
updated_by_user_id text,
published_by_user_id text,
author_json text,
created_by_json text,
updated_by_json text,
published_by_json text,
created_at text not null,
updated_at text not null,
published_at text,
scheduled_publish_at text,
deleted_at text
);
Working with the Schema
The repository layer in server/repositories/data/ provides type-safe methods for interacting with the unified schema.
Retrieving Table Definitions
Fetch a table definition by slug to inspect its fields before querying rows:
import { tables } from '@server/repositories/data'
// Get the table record for the built-in "posts" collection
const postsTable = await tables.getBySlug('posts')
Creating Custom Collections
Define new content types by inserting into data_tables with a structured fields array:
import { tables } from '@server/repositories/data'
import { DataTableKindSchema } from '@core/data/schemas'
await tables.create({
name: 'Products',
slug: 'products',
kind: DataTableKindSchema.enum.data,
singularLabel: 'Product',
pluralLabel: 'Products',
routeBase: '/products',
primaryFieldId: 'title',
fields: [
{ type: 'text', id: 'title', label: 'Title', required: true },
{ type: 'number', id: 'price', label: 'Price', required: true },
{ type: 'media', id: 'image', label: 'Image', mediaKind: 'image' },
],
system: false,
createdByUserId: userId,
updatedByUserId: userId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
Managing Row Data
Insert content as JSON cells linked to the table definition:
import { rows } from '@server/repositories/data'
await rows.create({
tableId: postsTable.id,
cells: {
title: 'Hello World',
body: '<p>First post!</p>',
slug: 'hello-world',
},
slug: 'hello-world',
status: 'draft',
authorUserId: userId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
Query published content using the repository's search method:
import { rows } from '@server/repositories/data'
const publishedPosts = await rows.search({
tableId: postsTable.id,
status: 'published',
})
Soft-delete rows to maintain referential integrity while hiding content:
await rows.softDelete(rowId) // Sets deleted_at to current timestamp
Summary
- Unified Schema: Instatic stores all content types in two tables—
data_tablesfor definitions anddata_rowsfor instances—eliminating the need for separate migration files per content type. - JSON Flexibility: Cell data and field definitions use JSON columns (
cells_json,fields_json), allowing dynamic schemas without altering database structure. - Type Safety: TypeBox schemas in
src/core/data/schemas.tsenforce valid table kinds, row statuses, and field configurations at runtime. - Referential Integrity: Foreign key constraints link
data_rows.table_idtodata_tables.id, while check constraints validate enum values forkindandstatus. - Soft Deletion: The
deleted_atcolumn enables non-destructive deletes, filtered automatically by the repository layer.
Frequently Asked Questions
What are the five table kinds supported in Instatic?
Instatic supports five table kinds defined in DataTableKindSchema: postType for CMS-managed content with built-in fields, data for arbitrary user-defined collections, page for editor-managed pages, component for visual components, and layout for saved layout snapshots. These kinds determine how the CMS renders and manages each collection.
How does soft deletion work in the data_rows table?
Soft deletion is implemented via the nullable deleted_at column in data_rows. When rows.softDelete(rowId) is called, the repository sets deleted_at to the current timestamp rather than removing the record. All query methods in server/repositories/data/rows/ automatically filter out rows where deleted_at is not null, ensuring deleted content remains recoverable without breaking foreign key relationships.
What foreign key constraints exist between data_tables and data_rows?
The data_rows table enforces a foreign key constraint on table_id that references data_tables(id) with on delete restrict. This prevents deletion of a table definition while rows still reference it, maintaining data integrity. The constraint is defined identically in both server/db/migrations-sqlite.ts and server/db/migrations-pg.ts.
Where are the TypeBox schemas for the database structure defined?
All TypeBox schemas defining the database schema structure—including DataTableSchema, DataRowSchema, DataTableKindSchema, DataRowStatusSchema, and DataFieldSchema—are located in src/core/data/schemas.ts. This file serves as the single source of truth for both TypeScript types and runtime validation logic.
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 →