How Instatic’s DataTables and DataRows Universal Content Store Operates
Instatic persists every piece of site content in two generic database tables—data_tables for schema definitions and data_rows for versioned JSON payloads—creating a dialect-agnostic universal content store that powers the CMS, visual editor, plugins, and static publishing pipeline.
Instatic is an open-source static site generator that abandons traditional content modeling in favor of a flexible, type-safe architecture. Instead of creating dedicated database tables for each content type, Instatic implements a universal content store using DataTables and DataRows, as defined in the CoreBunch/Instatic repository. This pair of generic tables handles everything from blog posts and pages to component definitions, enabling seamless integration across the admin interface, plugin ecosystem, and publishing engine.
Database Architecture and Abstraction
The universal content store sits atop a database abstraction layer that normalizes interactions across PostgreSQL and SQLite backends.
The DbClient Abstraction Layer
In src/core/persistence/dataTables.ts and src/core/persistence/dataRows.ts, the system interacts with the database through a dialect-agnostic DbClient interface. Whether configured with DATABASE_URL=postgres://… for PostgreSQL or using bun:sqlite for local development, all SQL remains ANSI-standard. The adapters automatically map JSON columns—denoted with the *_json suffix—to and from plain JavaScript objects, ensuring consistent behavior regardless of the underlying engine.
DataTables: The Schema Registry
The data_tables table, defined in src/core/persistence/dataTables.ts, functions as a schema registry for logical content collections. Each record stores a UUID, a unique slug (such as "Posts" or "Components"), and a JSON definition describing available fields, including their types, tokens, and validation rules. This metadata enables the CMS to render dynamic forms and validates incoming data without requiring schema migrations for new content types.
DataRows: Versioned Content Storage
Actual content items live in the data_rows table, implemented in src/core/persistence/dataRows.ts. Each row maintains a foreign key reference (table_id) to its parent DataTable, along with a data_json column containing the serialized field values. Crucially, every write creates a new version, with the version allocator logic tested in src/__tests__/server/dataRowVersionAllocator.test.ts. The version column auto-increments per table, enabling safe drafts, rollback capabilities, and full audit trails without destructive updates.
Content Lifecycle and Versioning
Understanding how content flows through Instatic requires examining the intersection of schema definition, row mutation, and version control.
Schema Definition Process
When developers or plugins register a new content type via the CMS UI, the system inserts a record into data_tables containing the JSON schema definition. This schema describes each field’s type, token, and constraints. The admin UI hook in src/admin/pages/data/hooks/useDataWorkspace.ts relies on these definitions to render appropriate input components and validation logic, fetching available tables via the listCmsDataTables() function.
Row Creation and Version Allocation
Creating content triggers an insertion into data_rows with the appropriate table_id and initial data_json payload. Subsequent edits do not overwrite existing records; instead, the mutation engine in src/core/page-tree/mutations.ts allocates a new version number and inserts a fresh row. This append-only approach ensures that the most recent version serves the editor while historical versions remain accessible for rollback or audit purposes.
API Surface and Integration Points
The universal content store exposes a consistent API across HTTP endpoints, admin hooks, and plugin SDKs.
Admin UI and HTTP API
CRUD operations are centralized in server/handlers/cms/data.ts, where handlers like listCmsDataTables(), createDataRow(), and updateDataRow() process requests. The admin interface consumes these through the shared apiRequest client from @core/http, as demonstrated in src/admin/pages/data/hooks/useDataWorkspace.ts. All responses are validated against TypeBox schemas, guaranteeing type safety across the JavaScript-to-database boundary.
Plugin SDK and MCP Bridge
Plugins interact with the store through multiple pathways. The direct SDK in src/core/plugins/sdk.ts allows internal plugin code to call mutation methods, while external integrations use the MCP (Model Context Protocol) bridge via server/ai/mcp/editorBridge.ts. Regardless of entry point, plugin-initiated changes route through the same mutation engine used by the core CMS, ensuring consistent validation and undo history. The RPC method cms.content.table.mutate handles these cross-boundary requests.
Publishing Pipeline Integration
During static site generation, the publisher resolves DataRow references to inject live content into the final HTML. In src/core/publisher/dynamicDetection.ts, the system analyzes the page’s NodeTree to identify nodes referencing DataRows. For each reference found, the publisher calls fetchDataRow() to retrieve the current data_json payload, replacing placeholder nodes with actual content before writing the resulting HTML to the static artifact directory managed by server/publish/staticArtefact.ts.
Code Examples: Working with the Store
The following patterns demonstrate how to interact with the universal content store across different contexts.
Listing DataTables in the Admin UI
Admin components use the @core/http client to fetch available content types:
import { listCmsDataTables } from '@core/http'
async function loadTables() {
const tables = await listCmsDataTables()
// tables: Array<{ id: string; slug: string; fields: Array<{ id: string; token: string }> }>
setTables(tables)
}
Creating a New DataRow
Insert content items by posting to the CMS data endpoint with TypeBox schema validation:
import { apiRequest } from '@core/http'
async function createRow(tableId: string, payload: Record<string, unknown>) {
const newRow = await apiRequest('/cms/data/rows', {
method: 'POST',
body: { tableId, data: payload },
schema: DataRowSchema, // TypeBox schema for validation
})
return newRow.id
}
Plugin-Side Mutations
Plugins modify content through the unified mutation API:
// Inside a plugin RPC handler
await cms.content.table.mutate({
kind: 'updateRow',
tableId,
rowId,
data: { title: 'New title' },
})
Resolving DataRows During Publishing
The publisher injects row data during static generation:
import { fetchDataRow } from '@core/persistence'
export async function renderNode(node) {
if (node.type === 'dataRow') {
const row = await fetchDataRow(node.rowId)
return renderData(row.data_json) // inject the stored JSON into the page
}
// …other node types
}
Summary
- Universal Schema: Instatic uses two generic tables—
data_tablesfor schemas anddata_rowsfor content—rather than dedicated tables per content type, defined insrc/core/persistence/dataTables.tsandsrc/core/persistence/dataRows.ts. - Dialect Agnostic: The
DbClientabstraction supports both PostgreSQL and SQLite using ANSI-standard SQL with automatic JSON column mapping. - Immutable Versioning: Every edit creates a new row version (auto-incremented per table), enabling drafts, rollback, and audit trails without data loss.
- Unified API: All mutations flow through
src/core/page-tree/mutations.ts, accessible via HTTP handlers, admin hooks (useDataWorkspace.ts), and the Plugin SDK/MC Bridge. - Publishing Integration: The publisher resolves DataRow references at build time using
src/core/publisher/dynamicDetection.ts, injecting JSON payloads into the final static HTML.
Frequently Asked Questions
What database dialects does the universal content store support?
The store supports both PostgreSQL (via DATABASE_URL=postgres://…) and SQLite (via bun:sqlite), as implemented in the dialect-agnostic DbClient interface. All SQL remains ANSI-standard, and the abstraction layer automatically handles JSON column serialization for both backends.
How does versioning work in the DataRows table?
Each write operation creates a new row in data_rows with an auto-incremented version number specific to that table. The version allocator logic, tested in src/__tests__/server/dataRowVersionAllocator.test.ts, ensures that drafts and historical versions persist alongside the latest content, enabling one-click rollback and full content auditing.
Can plugins safely modify content through the universal store?
Yes. Plugins interact with the store through the MCP Bridge (server/ai/mcp/editorBridge.ts) or the direct SDK (src/core/plugins/sdk.ts). All plugin-initiated mutations route through the central mutation engine in src/core/page-tree/mutations.ts, ensuring they undergo the same validation and type-checking as core CMS operations.
How does the publisher handle DataRow references during static generation?
During the publish run, the system walks the page’s NodeTree and identifies DataRow references using the dynamic detection logic in src/core/publisher/dynamicDetection.ts. For each reference, it fetches the current data_json payload from data_rows and injects the content directly into the HTML output, producing fully static pages with up-to-date dynamic content.
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 →