Instatic Database Schema for Custom Data Tables and Collections: Complete Technical Guide
Instatic stores all content—pages, posts, and custom collections—in a unified two-table architecture consisting of data_tables for schema metadata and data_rows for actual records, eliminating the need for traditional database migrations when creating new content types.
The CoreBunch/Instatic repository implements a headless CMS architecture where dynamic content types coexist with system tables through a single, flexible database schema. This design allows developers to define custom collections via JSON configuration rather than DDL statements, making schema evolution seamless and version-controlled.
The Unified Store Architecture
According to the docs/features/content-storage.md documentation, Instatic operates on a unified content store comprising two fundamental tables:
data_tables– Stores schema metadata for every collection, including field definitions and routing configurationdata_rows– Stores the actual content records, linked to their parent table via foreign key
This approach means pages, blog posts, visual components, and user-defined collections all share the same physical storage structure, differentiated only by metadata flags and JSON-encoded field schemas.
Table Kinds and Type Classification
The data_tables.kind column uses a SQLite CHECK constraint to enforce five distinct collection types:
| Kind | Purpose |
|---|---|
postType |
Custom content types (e.g., Products, Events) |
page |
Routable pages with URL paths |
component |
Visual components (VCs) stored as data rows |
layout |
Template layout definitions |
data |
Generic key-value tables for specialized use |
This enum is enforced at the database level in server/db/migrations-sqlite.ts:
kind text not null check (kind in ('postType','data','page','component','layout'))
Core Schema Columns
Understanding the column structure is critical for direct database access or custom queries:
data_tables columns:
id– Primary key (UUID)name– Human-readable label for admin UIslug– URL-friendly identifier, uniquely indexed viadata_tables_slug_active_idxroute_base– Base path for public URLs (e.g.,/blog)fields_json– JSON array defining custom fields (DataField schema)system– Boolean flag protecting built-in tables from deletion
data_rows columns:
table_id– Foreign key todata_tables.idwithON DELETE RESTRICTslug– Row-level identifier for public URLsvalues_json– JSON object containing the actual field valuescreated_at/updated_at– Audit timestamps
Creating Custom Collections
To define a new collection, you insert metadata into data_tables with kind: 'postType'. The fields_json column accepts an array of DataField objects defining text, number, boolean, markdown, or date fields.
Database Migration
The initial schema creation in server/db/migrations-sqlite.ts establishes the universal table structure:
await db`
create table if not exists data_tables (
id text primary key,
name text not null,
slug text not null,
kind text not null check (kind in ('postType','data','page','component','layout')),
route_base text,
singular_label text,
plural_label text,
primary_field_id text,
system integer not null default 0,
fields_json text,
created_at timestamp default CURRENT_TIMESTAMP,
updated_at timestamp default CURRENT_TIMESTAMP
);
create unique index if not exists data_tables_slug_active_idx on data_tables (slug);
`;
Programmatic Table Creation
Using the repository layer in server/repositories/data/tables.ts:
import { createDataTable } from '@server/repositories/data/tables';
import { DataField } from '@core/data/schemas';
const fields: DataField[] = [
{ type: 'text', id: 'title', label: 'Title' },
{ type: 'markdown', id: 'body', label: 'Body' },
{ type: 'date', id: 'publishDate', label: 'Publish Date' },
];
await createDataTable({
name: 'Blog Posts',
slug: 'blog',
kind: 'postType',
route_base: 'blog',
singular_label: 'Post',
plural_label: 'Posts',
fields_json: JSON.stringify(fields),
});
CRUD Operations via Repository Layer
All data access flows through typed repository functions in server/repositories/data/, providing type safety and referential integrity checks.
Inserting Records
To add content to a custom collection, use the insertRow function from server/repositories/data/rows/mutations.ts:
import { insertRow } from '@server/repositories/data/rows/mutations';
await insertRow({
table_id: '<data_tables.id for blog>',
slug: 'my-first-post',
values_json: JSON.stringify({
title: 'My First Post',
body: '# Hello World\nWelcome to my blog.',
publishDate: '2024-08-02',
}),
});
The repository automatically handles JSON serialization and foreign key validation, ensuring table_id references existing records protected by ON DELETE RESTRICT constraints.
Public URL Resolution
When data_tables.route_base is non-null, Instatic constructs public URLs using the pattern:
/<route_base>/<row_slug>
The server/publish/publicRouter.ts file implements this resolution logic by first looking up the table via its slug, then fetching the specific row, and finally rendering the appropriate template for that collection type.
System Tables vs Custom Collections
Instatic seeds four protected system tables on first run, identified by system = 1:
| Table | Kind | Purpose |
|---|---|---|
pages |
page |
Static site pages |
posts |
post |
Legacy content type |
components |
component |
Visual component library |
layouts |
layout |
Page layout templates |
These tables cannot be deleted via the admin UI or API. All user-created collections have system = 0 and can be modified or removed without affecting core functionality.
Schema Evolution and Field Updates
To modify a collection's structure (adding or removing fields), update the fields_json column in the corresponding data_tables row. The repository layer validates changes against TypeBox schemas, and existing rows remain valid because field values are optional unless explicitly marked required in the JSON schema. This approach eliminates the need for database migrations when evolving content models.
Summary
- Instatic implements a unified content store using
data_tablesfor metadata anddata_rowsfor records, as implemented in the CoreBunch/Instatic repository. - Five table kinds (
postType,page,component,layout,data) categorize collections with database-level constraints. - Custom fields are defined declaratively via JSON in the
fields_jsoncolumn, supporting text, markdown, date, and other DataField types. - The repository layer in
server/repositories/data/provides type-safe CRUD operations without requiring raw SQL. - System tables (
pages,posts,components,layouts) are protected by thesystemflag and cannot be deleted. - Public URLs resolve automatically based on
route_baseand rowslugvalues inserver/publish/publicRouter.ts.
Frequently Asked Questions
What is the Instatic database schema for custom data tables?
Instatic uses a two-table schema where data_tables stores collection metadata (names, slugs, field definitions) and data_rows stores the actual content records. Each row in data_rows links to its parent table via a foreign key, allowing unlimited custom collections without creating new database tables.
How does Instatic handle custom fields without database migrations?
Custom fields are stored as JSON in the fields_json column of data_tables, defining field types, labels, and validation rules. When you add fields to a collection, you update this JSON column rather than altering the database schema. The values_json column in data_rows stores the actual data as JSON, making the schema dynamically extensible.
What are the system tables in Instatic and can they be deleted?
The four system tables are pages (kind: page), posts (kind: post), components (kind: component), and layouts (kind: layout). These are protected by the system = 1 flag in their data_tables records and cannot be deleted through the API or admin interface, ensuring core functionality remains intact.
How are public URLs generated for custom collections in Instatic?
Public URLs follow the pattern /<route_base>/<row_slug>, where route_base comes from the data_tables record and slug comes from the specific row in data_rows. The server/publish/publicRouter.ts file handles this resolution, looking up the table by slug first, then fetching the corresponding row to render the 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 →