Skip to content

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
);

Released under the ISC License.