Building a Graph-Native CMS: Why Your CMS Has a 3-digit Number Of Tables, And Mine Has 12 Entities
TL;DR: Remember when I showed you how FLXBL could find the shortest path between any two actors in six degrees or less? Cool party trick. Now I've built something practical: a content management system called (of course) Kickass CMS that proves graph databases aren't just for path-finding—they're genuinely better for content-first applications. Here's what I learned building content workflows, revision systems, and hierarchical pages on top of FLXBL.
From Kevin Bacon to Content Management
In my last post, I demonstrated FLXBL's graph-native capabilities by building a movie database that could trace Hollywood connections through shared filmography. It was fun, visually impressive, and made for great cocktail party conversation.
But here's the thing: while "six degrees of separation" is a neat trick, it's not exactly an everyday use case. What is an everyday use case? Content management. And as it turns out, CMSes are absolutely riddled with relationships that make them a perfect candidate for graph-native architecture.
Think about it:
- Content belongs to authors (with roles like primary, contributor, editor)
- Content has categories (potentially featured, with custom ordering)
- Content includes media (featured images, galleries, inline images, attachments)
- Content follows workflow states (draft → review → approved → published → archived)
- Content has revisions (version history with author attribution)
- Pages have sections (which contain content, or reference global blocks)
- Pages form hierarchies (parent/child relationships for URL paths)
- Categories form hierarchies (nested taxonomies)
In a traditional relational CMS, each of these relationships means yet another junction table. Want to track when an author contributed to a piece? That's a column. Want to mark content as featured in a category? Another column. Want to record the role of media in content? You guessed it.
I've been down that road. The migrations alone will give you nightmares.
The Schema: Thinking in Graphs (Again)
When I started designing Kickass CMS, I approached it the same way I approached the movie database: what are the entities, and how do they connect?
The Core Entities
| Entity | Purpose | Key Fields |
|---|---|---|
| Content | Posts, articles, landing pages | title, slug, contentType, publishedAt, tags |
| Author | Content creators | name, email, bio, avatarUrl |
| Category | Hierarchical organization | name, slug, description |
| Media | Images, files, videos | filename, url, mimeType, size |
| ContentBlock | Block-based content structure | blockType, content, position |
| ContentRevision | Version history snapshots | revisionNumber, blocksSnapshot, isCurrent |
| WorkflowState | Content lifecycle states | name, slug, allowedTransitions |
| Page | CMS pages with custom routes | title, path, isPublished, showInNav |
| PageSection | Modular page content areas | sectionType, config, position |
| Layout | Reusable page templates | name, regions, template |
| LayoutPlacement | Content placement in layout regions | region, position, settings |
| Block | Global reusable widgets | name, blockType, content |
That's 12 entities. In a traditional CMS, you'd be looking at at least 20+ tables once you factor in all the junction tables for relationships.
The Relationships (Where FLXBL Shines)
Here's where it gets interesting. Let me show you the relationship model:
| Relationship | Direction | Properties |
|---|---|---|
AUTHORED_BY |
Content → Author |
role (PRIMARY/CONTRIBUTOR/EDITOR), byline
|
CATEGORIZED_AS |
Content → Category |
featured, position
|
HAS_MEDIA |
Content → Media |
role (FEATURED/GALLERY/INLINE/ATTACHMENT), position, caption
|
HAS_BLOCK |
Content → ContentBlock | position |
HAS_STATE |
Content → WorkflowState |
assignedAt, assignedBy
|
HAS_REVISION |
Content → ContentRevision | — |
REVISION_CREATED_BY |
ContentRevision → Author | — |
STATE_TRANSITION |
WorkflowState → WorkflowState |
requiresApproval, notifyRoles
|
PAGE_PARENT |
Page → Page | — |
CATEGORY_PARENT |
Category → Category | — |
PAGE_HAS_SECTION |
Page → PageSection | — |
PAGE_FILTERS_CATEGORY |
Page → Category | — |
SECTION_HAS_CONTENT |
PageSection → Content | position |
Notice something? The relationships carry data. When I attach an author to content, I'm not just recording that connection—I'm recording what kind of author they are. When I assign media, I'm capturing how that media is used.
In SQL, this would be:
CREATE TABLE content_authors (
content_id UUID REFERENCES content(id),
author_id UUID REFERENCES authors(id),
role VARCHAR(20) CHECK (role IN ('PRIMARY', 'CONTRIBUTOR', 'EDITOR')),
byline VARCHAR(255),
PRIMARY KEY (content_id, author_id)
);
CREATE TABLE content_media (
content_id UUID REFERENCES content(id),
media_id UUID REFERENCES media(id),
role VARCHAR(20) CHECK (role IN ('FEATURED', 'GALLERY', 'INLINE', 'ATTACHMENT')),
position INTEGER,
caption TEXT,
PRIMARY KEY (content_id, media_id)
);
-- ... repeat for every relationship with properties
In FLXBL, these properties just... exist on the edges. The schema declaration handles it:
{
"name": "AUTHORED_BY",
"sourceEntityName": "Content",
"targetEntityName": "Author",
"cardinality": "MANY_TO_MANY",
"fields": [
{ "name": "role", "type": "ENUM", "required": true, "enumValues": ["PRIMARY", "CONTRIBUTOR", "EDITOR"] },
{ "name": "byline", "type": "STRING", "required": false }
]
}
When I query for content with its authors, the role comes along for the ride—in a single traversal, not a JOIN.
The Implementation: What Made Life Easier
Let me walk through some real code from Kickass CMS that shows the difference.
Workflow State Machine
One of the most relationship-heavy features in any CMS is the workflow system. Content moves through states: Draft → In Review → Approved → Published → Archived. But the transitions aren't arbitrary—you can't go from Draft directly to Published in a professional editorial workflow.
In FLXBL, I model this with the STATE_TRANSITION relationship:
Draft ──[STATE_TRANSITION]──> In Review
In Review ──[STATE_TRANSITION]──> Approved
In Review ──[STATE_TRANSITION]──> Draft (send back)
Approved ──[STATE_TRANSITION]──> Published
Published ──[STATE_TRANSITION]──> Archived
Archived ──[STATE_TRANSITION]──> Draft (resurrect)
The workflow state entity stores allowed transitions as a simple array, and the actual transitions can carry properties like requiresApproval or notifyRoles.
Here's how I transition content between states:
// src/lib/flxbl/workflow.ts
export async function transitionState(
client: FlxblClient,
contentId: string,
newStateId: string,
assignedBy?: string
): Promise<void> {
// Get current state
const currentStateRel = await getContentState(client, contentId);
// Delete existing state relationship
if (currentStateRel) {
await client.deleteRelationship(
"Content", contentId, "HAS_STATE", currentStateRel.state.id
);
}
// Create new state relationship with metadata
await client.createRelationship(
"Content", contentId, "HAS_STATE", newStateId,
{
assignedAt: new Date(),
assignedBy: assignedBy ?? "system",
}
);
// Auto-set publishedAt when transitioning to published
const newState = await client.get("WorkflowState", newStateId);
if (newState.slug === "published") {
await client.patch("Content", contentId, { publishedAt: new Date() });
}
}
The HAS_STATE relationship doesn't just connect content to a state—it records when the transition happened and who made it. That's audit logging baked into the data model, not bolted on.
The Revision System
Version control for content is another relationship-heavy feature. Every time you save, you want to capture a snapshot that can be restored later. Each revision needs to track which author created it.
Here's how revisions work in Kickass CMS:
// src/lib/flxbl/revisions.ts
export async function createRevision(
client: FlxblClient,
contentId: string,
authorId: string,
changeMessage?: string
): Promise<ContentRevision> {
// Get current blocks
const blocks = await loadContentBlocks(client, contentId);
// Get existing revisions to determine revision number
const existingRels = await client.getRelationships(
"Content", contentId, "HAS_REVISION", "out", "ContentRevision"
);
// Mark all existing revisions as not current
for (const rel of existingRels) {
if (rel.target.isCurrent) {
await client.patch("ContentRevision", rel.target.id, { isCurrent: false });
}
}
// Create snapshot as an object with positions as keys
const blocksObj: Record<string, unknown> = {};
for (const b of blocks) {
blocksObj[String(b.position)] = {
blockType: b.blockType,
content: b.content,
metadata: b.metadata,
};
}
// Create the revision
const revision = await client.create("ContentRevision", {
revisionNumber: existingRels.length + 1,
title: (await client.get("Content", contentId)).title,
blocksSnapshot: blocksObj,
changeMessage: changeMessage ?? null,
isCurrent: true,
});
// Link revision to content and author
await client.createRelationship("Content", contentId, "HAS_REVISION", revision.id, {});
await client.createRelationship("ContentRevision", revision.id, "REVISION_CREATED_BY", authorId, {});
return revision;
}
When I restore a revision, I create a new revision that records the restoration:
export async function restoreRevision(
client: FlxblClient,
contentId: string,
revisionId: string,
authorId: string
): Promise<ContentRevision> {
const revision = await client.get("ContentRevision", revisionId);
// Delete existing blocks and recreate from snapshot
// ... (block restoration code)
// Create a NEW revision recording this restore
return createRevision(
client, contentId, authorId,
`Restored from revision #${revision.revisionNumber}`
);
}
This means you can never lose history. If you restore revision #3 from revision #7, you get revision #8 that points back to #3. Time travel without paradoxes. (Doc Brown would be proud.)
Hierarchical Pages
Pages in Kickass CMS support parent/child relationships for building URL hierarchies:
/about (root page)
/about/team (child)
/about/team/leadership (grandchild)
The PAGE_PARENT relationship handles this elegantly. When I need to build the navigation tree:
// Get all pages with their parent relationships
const pages = await client.list("Page");
const tree: PageNode[] = [];
for (const page of pages) {
const parentRels = await client.getRelationships(
"Page", page.id, "PAGE_PARENT", "out", "Page"
);
// If no parent, it's a root page
if (parentRels.length === 0) {
tree.push(buildPageTree(page, pages));
}
}
The path field auto-updates when you change a page's parent. Changing the parent of "team" from "about" to "company" automatically updates its path from /about/team to /company/team. No manual URL rewriting.
The Content Editor: Tiptap + FLXBL
Kickass CMS uses Tiptap 3 for rich text editing. Tiptap outputs a JSON document structure that maps beautifully to graph-native storage.
Here's the conversion layer:
// src/lib/flxbl/blocks.ts
export function tiptapToBlocks(doc: TiptapDoc): CreateContentBlock[] {
const blocks: CreateContentBlock[] = [];
doc.content.forEach((node, index) => {
const blockType = mapTiptapTypeToBlockType(node.type);
if (!blockType) return;
const content = tiptapNodeToBlockContent(node, blockType);
blocks.push({
blockType,
content,
position: index,
metadata: {},
});
});
return blocks;
}
Each content block becomes its own entity, connected to the parent content via HAS_BLOCK. This means I can:
- Query for all content that contains a specific image
- Find all code blocks using TypeScript
- Build a "related content" feature based on shared block types
These are graph traversals, not full-table scans.
What Made This Easier Than SQL
Let me be specific about the developer experience improvements:
1. No Migration Hell
In a SQL-based CMS, adding a new relationship property means:
ALTER TABLE content_authors ADD COLUMN contribution_percentage DECIMAL(5,2);
And then you need to run migrations, handle null values for existing records, update your ORM models, regenerate types...
In FLXBL, I update the schema definition and publish:
{
"name": "role",
"type": "NUMBER",
"required": false,
"description": "Contribution percentage"
}
Done. The API immediately accepts the new property.
2. Relationship Properties Are First-Class
When I query content with authors, I get the relationship properties automatically:
const authorRels = await client.getRelationships(
"Content", contentId, "AUTHORED_BY", "out", "Author"
);
// Each result has both the target entity AND the relationship properties
for (const rel of authorRels) {
console.log(`${rel.target.name} - ${rel.relationship.properties.role}`);
// "Jane Smith - PRIMARY"
// "Bob Jones - CONTRIBUTOR"
}
No JOINs. No separate queries. Just data.
3. Type Safety All The Way Down
I generated Zod schemas from the FLXBL schema using the MCP tool:
export const AuthoredByPropsSchema = z.object({
role: z.enum(["PRIMARY", "CONTRIBUTOR", "EDITOR"]),
byline: z.string().nullish(),
});
export type AuthoredByProps = z.infer<typeof AuthoredByPropsSchema>;
The FLXBL client validates responses at runtime. If the API returns something unexpected, Zod catches it immediately—not after it's caused a cryptic error in the UI.
4. Hierarchy Queries Are Trivial
Building a category tree in SQL:
WITH RECURSIVE category_tree AS (
SELECT id, name, slug, NULL::UUID as parent_id, 0 as depth
FROM categories WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, c.slug, c.parent_id, ct.depth + 1
FROM categories c
JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT * FROM category_tree ORDER BY depth, name;
In FLXBL:
const categories = await client.list("Category");
const parentRels = await client.getRelationships(
"Category", categoryId, "CATEGORY_PARENT", "out", "Category"
);
The graph model means I'm traversing edges, not doing recursive self-joins.
Live Demo (With a Caveat)
You can try Kickass CMS right now:
- Live Demo: kickass-cms.vercel.app
- GitHub: github.com/flxbl-dev/kickass-cms
A Note on Performance
You might notice the demo isn't blazingly fast. Here's why: it's running on Vercel without ISR caching enabled, and FLXBL is currently running on a single server with rate limits.
This isn't a FLXBL limitation—it's intentional for the public beta. Rate limiting prevents any single demo from DDoSing the shared infrastructure. In a production scenario with dedicated resources and proper caching, response times would be dramatically better.
The demo is about showcasing the architecture and developer experience, not benchmarking cold-start Lambdas against a rate-limited API. Once you're running your own FLXBL tenant with appropriate infrastructure, performance is a non-issue.
The Tech Stack
| Layer | Technology | Why |
|---|---|---|
| Framework | Next.js 16 | App Router, RSC, API routes |
| UI | React 19 + Tailwind CSS 4 | Modern, fast, flexible |
| Components | shadcn/ui | Accessible primitives |
| Editor | Tiptap 3 | Block-based, extensible, outputs JSON |
| Validation | Zod | Runtime type safety |
| Backend | FLXBL | Graph-native BaaS |
The entire admin interface is server-rendered. Content management happens via API routes that call the FLXBL client. The public site uses static generation where possible, with dynamic routes for category/author/tag archives.
What's Next
Kickass CMS is a proof of concept—a demonstration that graph-native backends can power real-world applications beyond "find me the shortest path."
Things I'm considering for future iterations:
- Real-time previews using FLXBL's GraphQL subscriptions
- Multi-tenant support so each customer gets isolated content
- Media processing pipeline with automatic image optimization
- Full-text search integration with Elasticsearch or Meilisearch
- Import/export for migrating from WordPress, Ghost, etc.
Try It Yourself
If you're building something content-heavy and tired of junction table spaghetti:
- Sign up at platform.flxbl.dev
- Clone the repo:
git clone https://github.com/flxbl-dev/kickass-cms - Add your FLXBL credentials to
.env.local - Run
pnpm devand start exploring
The codebase includes:
- Complete admin interface with content editing
- Revision system with restore functionality
- Workflow state management
- Hierarchical pages with category filtering
- Block-based content with Tiptap integration
The Bottom Line
CMSes are fundamentally about content and the relationships between content. Authors write posts. Posts belong to categories. Categories have hierarchies. Media gets attached with specific roles. Workflow moves content through states.
Every one of these is a relationship. And when your database treats relationships as second-class citizens (foreign keys, junction tables, recursive CTEs), you spend more time fighting the data model than building features.
FLXBL's graph-native approach isn't just theoretically cleaner—it's practically faster to develop with. The Kickass CMS schema has 12 entities and 18 relationships. In Postgres, that would be 30+ tables. In FLXBL, it's just... 12 entities and 18 relationships.
What CMS features have you struggled to implement cleanly in a relational database? I'd love to hear about the workarounds you've built—and whether a graph-native approach might have helped.
Marko Mijailović is the creator of FLXBL. You can find him on LinkedIn, reach out through email, or join the FLXBL Discord.


Top comments (0)