CMS Use Case
Build a headless content management system with Backflow.
Overview
This guide demonstrates building a CMS with:
- Content types and fields
- Publishing workflows
- Media management
- Version history
- SEO metadata
Route Configuration
json
{
"routeAliases": {
"content": "/api/v1/content",
"media": "/api/v1/media",
"authors": "/api/v1/authors"
}
}Content Management
List Content
json
{
"path": "/content/:type",
"method": "get",
"supabaseQueries": [{
"table": "content",
"operation": "select",
"select": "id, title, slug, excerpt, featured_image, status, published_at, authors(name, avatar)",
"filters": [
{ "column": "content_type", "operator": "eq", "value": "{{params.type}}" },
{ "column": "status", "operator": "eq", "value": "{{query.status || 'published'}}" }
],
"order": { "column": "published_at", "ascending": false },
"limit": "{{query.limit || 20}}",
"offset": "{{query.offset || 0}}"
}],
"cache": { "enabled": true, "ttl": 300 }
}Get Content by Slug
json
{
"path": "/content/:type/:slug",
"method": "get",
"supabaseQueries": [{
"table": "content",
"operation": "select",
"select": "*, authors(name, bio, avatar), categories(name, slug), tags(name)",
"filters": [
{ "column": "content_type", "operator": "eq", "value": "{{params.type}}" },
{ "column": "slug", "operator": "eq", "value": "{{params.slug}}" },
{ "column": "status", "operator": "eq", "value": "published" }
],
"single": true
}],
"cache": {
"enabled": true,
"ttl": 600,
"key": "content:{{params.type}}:{{params.slug}}"
}
}Create Content
json
{
"path": "/content/:type",
"method": "post",
"requireAuth": true,
"rbac": { "roles": ["admin", "editor", "author"] },
"validation": {
"body": {
"title": { "type": "string", "required": true, "maxLength": 200 },
"slug": { "type": "string", "pattern": "^[a-z0-9-]+$" },
"body": { "type": "string", "required": true },
"excerpt": { "type": "string", "maxLength": 500 },
"featured_image": { "type": "string" },
"category_id": { "type": "string" },
"tags": { "type": "array", "items": { "type": "string" } }
}
},
"supabaseQueries": [{
"table": "content",
"operation": "insert",
"data": {
"content_type": "{{params.type}}",
"title": "{{body.title}}",
"slug": "{{body.slug || slugify(body.title)}}",
"body": "{{body.body}}",
"excerpt": "{{body.excerpt}}",
"featured_image": "{{body.featured_image}}",
"category_id": "{{body.category_id}}",
"author_id": "{{auth.sub}}",
"status": "draft"
},
"select": "*"
}]
}Update Content
json
{
"path": "/content/:type/:id",
"method": "put",
"requireAuth": true,
"rbac": { "roles": ["admin", "editor", "author"] },
"workflow": {
"steps": [
{
"id": "save-version",
"type": "database",
"params": {
"table": "content_versions",
"operation": "insert",
"data": {
"content_id": "{{params.id}}",
"title": "{{context.content.title}}",
"body": "{{context.content.body}}",
"version": "{{context.content.version}}",
"created_by": "{{auth.sub}}"
}
}
},
{
"id": "update-content",
"type": "database",
"params": {
"table": "content",
"operation": "update",
"data": {
"title": "{{body.title}}",
"body": "{{body.body}}",
"excerpt": "{{body.excerpt}}",
"featured_image": "{{body.featured_image}}",
"version": "content.version + 1",
"updated_at": "now()"
},
"filters": [
{ "column": "id", "operator": "eq", "value": "{{params.id}}" }
]
}
}
]
}
}Publishing Workflow
Submit for Review
json
{
"path": "/content/:id/submit",
"method": "post",
"requireAuth": true,
"rbac": { "roles": ["author"] },
"supabaseQueries": [{
"table": "content",
"operation": "update",
"data": {
"status": "pending_review",
"submitted_at": "now()"
},
"filters": [
{ "column": "id", "operator": "eq", "value": "{{params.id}}" },
{ "column": "author_id", "operator": "eq", "value": "{{auth.sub}}" }
]
}],
"integrations": [{
"type": "slack",
"action": "sendMessage",
"params": {
"channel": "#content-review",
"text": "New content submitted for review: {{context.content.title}}"
}
}]
}Publish Content
json
{
"path": "/content/:id/publish",
"method": "post",
"requireAuth": true,
"rbac": { "roles": ["admin", "editor"] },
"supabaseQueries": [{
"table": "content",
"operation": "update",
"data": {
"status": "published",
"published_at": "{{body.scheduled_at || 'now()'}}",
"published_by": "{{auth.sub}}"
},
"filters": [
{ "column": "id", "operator": "eq", "value": "{{params.id}}" }
],
"select": "*, authors(email, name)"
}],
"integrations": [{
"type": "sendgrid",
"action": "sendTemplate",
"params": {
"to": "{{result.authors.email}}",
"templateId": "content-published",
"dynamicTemplateData": {
"title": "{{result.title}}",
"url": "{{env.SITE_URL}}/{{result.content_type}}/{{result.slug}}"
}
}
}]
}Unpublish Content
json
{
"path": "/content/:id/unpublish",
"method": "post",
"requireAuth": true,
"rbac": { "roles": ["admin", "editor"] },
"supabaseQueries": [{
"table": "content",
"operation": "update",
"data": {
"status": "draft",
"published_at": null
},
"filters": [
{ "column": "id", "operator": "eq", "value": "{{params.id}}" }
]
}]
}Media Library
Upload Media
json
{
"path": "/media/upload",
"method": "post",
"requireAuth": true,
"rbac": { "roles": ["admin", "editor", "author"] },
"workflow": {
"steps": [
{
"id": "upload-file",
"type": "storage",
"action": "upload",
"params": {
"bucket": "media",
"path": "{{auth.tenant_id}}/{{formatDate(now(), 'YYYY/MM')}}/{{uuid()}}-{{file.name}}",
"file": "{{file}}"
}
},
{
"id": "save-record",
"type": "database",
"params": {
"table": "media",
"operation": "insert",
"data": {
"filename": "{{file.name}}",
"url": "{{steps.upload-file.result.url}}",
"mime_type": "{{file.type}}",
"size": "{{file.size}}",
"uploaded_by": "{{auth.sub}}"
}
}
}
]
}
}List Media
json
{
"path": "/media",
"method": "get",
"requireAuth": true,
"supabaseQueries": [{
"table": "media",
"operation": "select",
"select": "id, filename, url, mime_type, size, created_at",
"filters": [
{ "column": "mime_type", "operator": "ilike", "value": "{{query.type || '%'}}" }
],
"order": { "column": "created_at", "ascending": false },
"limit": "{{query.limit || 50}}"
}]
}Version History
Get Versions
json
{
"path": "/content/:id/versions",
"method": "get",
"requireAuth": true,
"supabaseQueries": [{
"table": "content_versions",
"operation": "select",
"select": "id, version, title, created_at, users(name)",
"filters": [
{ "column": "content_id", "operator": "eq", "value": "{{params.id}}" }
],
"order": { "column": "version", "ascending": false }
}]
}Restore Version
json
{
"path": "/content/:id/versions/:versionId/restore",
"method": "post",
"requireAuth": true,
"rbac": { "roles": ["admin", "editor"] },
"supabaseQueries": [
{
"table": "content_versions",
"operation": "select",
"filters": [
{ "column": "id", "operator": "eq", "value": "{{params.versionId}}" }
],
"single": true,
"alias": "version"
},
{
"table": "content",
"operation": "update",
"data": {
"title": "{{results.version.title}}",
"body": "{{results.version.body}}",
"updated_at": "now()"
},
"filters": [
{ "column": "id", "operator": "eq", "value": "{{params.id}}" }
]
}
]
}Database Schema
sql
CREATE TABLE content (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
content_type TEXT NOT NULL,
title TEXT NOT NULL,
slug TEXT NOT NULL,
body TEXT,
excerpt TEXT,
featured_image TEXT,
author_id UUID NOT NULL,
category_id UUID REFERENCES categories(id),
status TEXT DEFAULT 'draft',
version INTEGER DEFAULT 1,
published_at TIMESTAMPTZ,
published_by UUID,
submitted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(content_type, slug)
);
CREATE TABLE content_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
content_id UUID REFERENCES content(id),
title TEXT,
body TEXT,
version INTEGER,
created_by UUID,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
parent_id UUID REFERENCES categories(id)
);
CREATE TABLE media (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
filename TEXT NOT NULL,
url TEXT NOT NULL,
mime_type TEXT,
size INTEGER,
uploaded_by UUID,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE authors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
name TEXT NOT NULL,
bio TEXT,
avatar TEXT,
email TEXT
);