Skip to content

Building a Blog Platform with Centrali

This tutorial walks you through building a complete blog platform with Centrali, including posts, comments, user management, and content moderation.

What We'll Build

A full-featured blog platform with: - User registration and authentication - Creating and editing blog posts - Comments with moderation - Categories and tags - Search functionality - RSS feed generation - Email notifications - Analytics tracking

Step 1: Design the Data Model

First, let's create our data structures.

User Structure

// POST /workspace/{workspace}/api/v1/structures
{
  "name": "User",
  "fields": {
    "email": {
      "type": "email",
      "required": true,
      "unique": true
    },
    "username": {
      "type": "text",
      "required": true,
      "unique": true,
      "minLength": 3,
      "maxLength": 30,
      "pattern": "^[a-zA-Z0-9_]+$"
    },
    "displayName": {
      "type": "text",
      "required": true,
      "maxLength": 100
    },
    "bio": {
      "type": "longtext",
      "maxLength": 500
    },
    "avatar": {
      "type": "url"
    },
    "role": {
      "type": "select",
      "options": ["reader", "author", "editor", "admin"],
      "default": "reader"
    },
    "verified": {
      "type": "boolean",
      "default": false
    },
    "lastLogin": {
      "type": "datetime"
    },
    "preferences": {
      "type": "json",
      "default": {
        "emailNotifications": true,
        "theme": "light"
      }
    }
  }
}

BlogPost Structure

{
  "name": "BlogPost",
  "fields": {
    "title": {
      "type": "text",
      "required": true,
      "maxLength": 200
    },
    "slug": {
      "type": "text",
      "required": true,
      "unique": true,
      "pattern": "^[a-z0-9-]+$"
    },
    "content": {
      "type": "longtext",
      "required": true
    },
    "excerpt": {
      "type": "text",
      "maxLength": 300
    },
    "authorId": {
      "type": "reference",
      "structure": "User",
      "required": true
    },
    "categoryId": {
      "type": "reference",
      "structure": "Category"
    },
    "tags": {
      "type": "array",
      "items": {
        "type": "reference",
        "structure": "Tag"
      }
    },
    "featuredImage": {
      "type": "url"
    },
    "status": {
      "type": "select",
      "options": ["draft", "published", "archived"],
      "default": "draft"
    },
    "publishedAt": {
      "type": "datetime"
    },
    "viewCount": {
      "type": "number",
      "default": 0
    },
    "likes": {
      "type": "number",
      "default": 0
    },
    "seoTitle": {
      "type": "text",
      "maxLength": 60
    },
    "seoDescription": {
      "type": "text",
      "maxLength": 160
    },
    "allowComments": {
      "type": "boolean",
      "default": true
    }
  }
}

Comment Structure

{
  "name": "Comment",
  "fields": {
    "postId": {
      "type": "reference",
      "structure": "BlogPost",
      "required": true
    },
    "userId": {
      "type": "reference",
      "structure": "User",
      "required": true
    },
    "parentId": {
      "type": "reference",
      "structure": "Comment",
      "description": "For nested replies"
    },
    "content": {
      "type": "longtext",
      "required": true,
      "maxLength": 2000
    },
    "status": {
      "type": "select",
      "options": ["pending", "approved", "spam", "deleted"],
      "default": "pending"
    },
    "likes": {
      "type": "number",
      "default": 0
    },
    "edited": {
      "type": "boolean",
      "default": false
    },
    "editedAt": {
      "type": "datetime"
    }
  }
}

Category & Tag Structures

// Category Structure
{
  "name": "Category",
  "fields": {
    "name": {
      "type": "text",
      "required": true,
      "unique": true
    },
    "slug": {
      "type": "text",
      "required": true,
      "unique": true
    },
    "description": {
      "type": "text"
    },
    "parentId": {
      "type": "reference",
      "structure": "Category"
    }
  }
}

// Tag Structure
{
  "name": "Tag",
  "fields": {
    "name": {
      "type": "text",
      "required": true,
      "unique": true
    },
    "slug": {
      "type": "text",
      "required": true,
      "unique": true
    }
  }
}

Step 2: Implement Core Functions

Slug Generator Function

Automatically generate SEO-friendly URLs:

// Function: generateSlug
exports.handler = async (event, context) => {
  const { centrali, utils } = context.apis;
  const { title } = event.data;

  // Generate base slug
  let slug = title
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '');

  // Check for uniqueness
  let finalSlug = slug;
  let counter = 1;

  while (true) {
    const existing = await centrali.records.query({
      structure: 'BlogPost',
      filter: { slug: finalSlug },
      limit: 1
    });

    if (existing.length === 0) {
      break;
    }

    finalSlug = `${slug}-${counter}`;
    counter++;
  }

  return {
    success: true,
    data: { slug: finalSlug }
  };
};

Content Moderation Function

Check comments for spam and inappropriate content:

// Function: moderateComment
exports.handler = async (event, context) => {
  const { centrali, http } = context.apis;
  const comment = event.trigger.record;

  // Check for spam indicators
  const spamIndicators = [
    /viagra/i,
    /casino/i,
    /bit\.ly/i,
    /click here/i,
    /free money/i
  ];

  let isSpam = false;
  for (const pattern of spamIndicators) {
    if (pattern.test(comment.data.content)) {
      isSpam = true;
      break;
    }
  }

  // Check with external moderation API (optional)
  if (!isSpam && context.environment.MODERATION_API_KEY) {
    try {
      const response = await http.post('https://api.moderationapi.com/check', {
        body: {
          text: comment.data.content,
          lang: 'en'
        },
        headers: {
          'Authorization': `Bearer ${context.environment.MODERATION_API_KEY}`
        }
      });

      if (response.body.flagged) {
        isSpam = true;
      }
    } catch (error) {
      console.error('Moderation API error:', error);
    }
  }

  // Update comment status
  const status = isSpam ? 'spam' : 'approved';
  await centrali.records.update(comment.id, { status });

  // Notify author if approved
  if (status === 'approved') {
    const post = await centrali.records.get(comment.data.postId);
    const author = await centrali.records.get(post.data.authorId);

    if (author.data.preferences.emailNotifications) {
      await centrali.notifications.email({
        to: author.data.email,
        subject: 'New comment on your post',
        template: 'new-comment',
        data: {
          postTitle: post.data.title,
          commentContent: comment.data.content
        }
      });
    }
  }

  return { success: true, data: { status } };
};

View Counter Function

Track post views and analytics:

// Function: trackView
exports.handler = async (event, context) => {
  const { centrali, utils } = context.apis;
  const { postId, userId, ipAddress, userAgent } = event.data;

  // Check if this is a unique view (within 24 hours)
  const viewKey = utils.crypto.sha256(`${postId}-${userId || ipAddress}`);
  const recentView = await centrali.records.query({
    structure: 'Analytics',
    filter: {
      key: viewKey,
      createdAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() }
    },
    limit: 1
  });

  if (recentView.length === 0) {
    // Record unique view
    await centrali.records.create({
      structure: 'Analytics',
      data: {
        type: 'pageview',
        key: viewKey,
        postId,
        userId,
        ipAddress,
        userAgent,
        referrer: event.data.referrer
      }
    });

    // Increment view count
    const post = await centrali.records.get(postId);
    await centrali.records.update(postId, {
      viewCount: (post.data.viewCount || 0) + 1
    });
  }

  return { success: true };
};

RSS Feed Generator

Generate RSS feed for blog posts:

// Function: generateRSSFeed
exports.handler = async (event, context) => {
  const { centrali } = context.apis;

  // Get recent published posts
  const posts = await centrali.records.query({
    structure: 'BlogPost',
    filter: { status: 'published' },
    sort: '-publishedAt',
    limit: 20
  });

  // Build RSS XML
  const rssItems = await Promise.all(posts.map(async (post) => {
    const author = await centrali.records.get(post.data.authorId);

    return `
      <item>
        <title><![CDATA[${post.data.title}]]></title>
        <link>https://blog.example.com/posts/${post.data.slug}</link>
        <description><![CDATA[${post.data.excerpt || post.data.content.substring(0, 200)}]]></description>
        <author>${author.data.email} (${author.data.displayName})</author>
        <pubDate>${new Date(post.data.publishedAt).toUTCString()}</pubDate>
        <guid>https://blog.example.com/posts/${post.data.slug}</guid>
      </item>
    `;
  }));

  const rssFeed = `<?xml version="1.0" encoding="UTF-8"?>
    <rss version="2.0">
      <channel>
        <title>My Blog</title>
        <link>https://blog.example.com</link>
        <description>Latest posts from My Blog</description>
        <language>en-US</language>
        <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
        ${rssItems.join('')}
      </channel>
    </rss>
  `;

  // Store in cache
  await centrali.storage.upload({
    path: 'feeds/rss.xml',
    content: rssFeed,
    contentType: 'application/rss+xml',
    cacheControl: 'public, max-age=3600'
  });

  return {
    success: true,
    data: { url: 'https://cdn.example.com/feeds/rss.xml' }
  };
};

Step 3: Set Up Triggers

Auto-generate Slugs

// POST /workspace/{workspace}/api/v1/triggers
{
  "name": "AutoGenerateSlug",
  "type": "record",
  "config": {
    "structureId": "str_blogpost",
    "event": "beforeCreate",
    "condition": "!data.slug && data.title"
  },
  "functionId": "fn_generateSlug",
  "transform": {
    "data.slug": "result.data.slug"
  }
}

Moderate Comments

{
  "name": "ModerateNewComments",
  "type": "record",
  "config": {
    "structureId": "str_comment",
    "event": "afterCreate"
  },
  "functionId": "fn_moderateComment"
}

Generate RSS on Publish

{
  "name": "UpdateRSSFeed",
  "type": "record",
  "config": {
    "structureId": "str_blogpost",
    "event": "afterUpdate",
    "condition": "data.status === 'published' && previous.status !== 'published'"
  },
  "functionId": "fn_generateRSSFeed"
}

Schedule Daily Reports

{
  "name": "DailyAnalyticsReport",
  "type": "schedule",
  "config": {
    "expression": "0 9 * * *" // Daily at 9 AM
  },
  "functionId": "fn_generateAnalyticsReport"
}

Step 4: Frontend Integration

React Blog Component

import React, { useState, useEffect } from 'react';
import { CentraliClient } from '@centrali/sdk';

const client = new CentraliClient({
  workspace: 'my-blog',
  apiKey: process.env.REACT_APP_CENTRALI_KEY
});

function BlogList() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [page, setPage] = useState(0);

  useEffect(() => {
    fetchPosts();
  }, [page]);

  const fetchPosts = async () => {
    setLoading(true);
    try {
      const result = await client.query(`
        FROM BlogPost
        WHERE status = "published"
        ORDER BY publishedAt DESC
        LIMIT 10
        OFFSET ${page * 10}
        INCLUDE author FROM User WHERE id = BlogPost.authorId
        INCLUDE category FROM Category WHERE id = BlogPost.categoryId
      `);

      setPosts(result.data);
    } catch (error) {
      console.error('Error fetching posts:', error);
    }
    setLoading(false);
  };

  if (loading) return <div>Loading...</div>;

  return (
    <div className="blog-list">
      {posts.map(post => (
        <article key={post.id} className="blog-post">
          <h2>
            <a href={`/posts/${post.data.slug}`}>
              {post.data.title}
            </a>
          </h2>

          <div className="meta">
            By {post.author.data.displayName}
            in {post.category?.data.name}
             {new Date(post.data.publishedAt).toLocaleDateString()}
          </div>

          {post.data.featuredImage && (
            <img src={post.data.featuredImage} alt={post.data.title} />
          )}

          <p>{post.data.excerpt}</p>

          <a href={`/posts/${post.data.slug}`}>Read more </a>
        </article>
      ))}

      <div className="pagination">
        <button
          onClick={() => setPage(page - 1)}
          disabled={page === 0}
        >
          Previous
        </button>
        <button onClick={() => setPage(page + 1)}>
          Next
        </button>
      </div>
    </div>
  );
}

Blog Post Detail

function BlogPost({ slug }) {
  const [post, setPost] = useState(null);
  const [comments, setComments] = useState([]);
  const [newComment, setNewComment] = useState('');

  useEffect(() => {
    fetchPost();
    trackView();
  }, [slug]);

  const fetchPost = async () => {
    const result = await client.query(`
      FROM BlogPost
      WHERE slug = "${slug}"
      INCLUDE author FROM User WHERE id = BlogPost.authorId
      INCLUDE comments FROM Comment
        WHERE postId = BlogPost.id AND status = "approved"
        ORDER BY createdAt DESC
    `);

    setPost(result.data[0]);
    setComments(result.data[0].comments || []);
  };

  const trackView = async () => {
    await client.functions.execute('trackView', {
      postId: post.id,
      referrer: document.referrer
    });
  };

  const submitComment = async (e) => {
    e.preventDefault();

    const comment = await client.records.create({
      structure: 'Comment',
      data: {
        postId: post.id,
        userId: currentUser.id,
        content: newComment
      }
    });

    // Comment will be moderated automatically
    setNewComment('');
    alert('Comment submitted for moderation!');
  };

  const likePost = async () => {
    await client.records.update(post.id, {
      likes: post.data.likes + 1
    });

    setPost({
      ...post,
      data: {
        ...post.data,
        likes: post.data.likes + 1
      }
    });
  };

  if (!post) return <div>Loading...</div>;

  return (
    <article className="blog-post-detail">
      <h1>{post.data.title}</h1>

      <div className="meta">
        <img src={post.author.data.avatar} alt={post.author.data.displayName} />
        <div>
          <strong>{post.author.data.displayName}</strong>
          <time>{new Date(post.data.publishedAt).toLocaleDateString()}</time>
        </div>
      </div>

      {post.data.featuredImage && (
        <img src={post.data.featuredImage} alt={post.data.title} />
      )}

      <div
        className="content"
        dangerouslySetInnerHTML={{ __html: post.data.content }}
      />

      <div className="actions">
        <button onClick={likePost}>
          ❤️ Like ({post.data.likes})
        </button>
        <span>👁 {post.data.viewCount} views</span>
      </div>

      {post.data.allowComments && (
        <section className="comments">
          <h3>Comments ({comments.length})</h3>

          <form onSubmit={submitComment}>
            <textarea
              value={newComment}
              onChange={(e) => setNewComment(e.target.value)}
              placeholder="Write a comment..."
              required
            />
            <button type="submit">Post Comment</button>
          </form>

          {comments.map(comment => (
            <div key={comment.id} className="comment">
              <strong>{comment.user.data.displayName}</strong>
              <time>{new Date(comment.createdAt).toLocaleDateString()}</time>
              <p>{comment.data.content}</p>
            </div>
          ))}
        </section>
      )}
    </article>
  );
}

Search Component

function BlogSearch() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [searching, setSearching] = useState(false);

  const search = async (e) => {
    e.preventDefault();
    setSearching(true);

    const results = await client.search.query({
      index: 'blogposts',
      query: query,
      filters: 'status = "published"',
      facets: ['category', 'tags'],
      limit: 20
    });

    setResults(results.hits);
    setSearching(false);
  };

  return (
    <div className="search">
      <form onSubmit={search}>
        <input
          type="search"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="Search posts..."
        />
        <button type="submit" disabled={searching}>
          Search
        </button>
      </form>

      {results.length > 0 && (
        <div className="search-results">
          <h3>Found {results.length} posts</h3>
          {results.map(hit => (
            <div key={hit.id}>
              <h4>
                <a href={`/posts/${hit.slug}`}>
                  {hit._highlighted.title}
                </a>
              </h4>
              <p>{hit._highlighted.excerpt}</p>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Step 5: Admin Dashboard

Post Editor

function PostEditor({ postId }) {
  const [post, setPost] = useState({
    title: '',
    content: '',
    excerpt: '',
    categoryId: '',
    tags: [],
    status: 'draft',
    allowComments: true
  });

  const savePost = async (publish = false) => {
    const data = {
      ...post,
      status: publish ? 'published' : 'draft',
      publishedAt: publish ? new Date().toISOString() : null
    };

    if (postId) {
      await client.records.update(postId, data);
    } else {
      const newPost = await client.records.create({
        structure: 'BlogPost',
        data
      });
      setPostId(newPost.id);
    }
  };

  const uploadImage = async (file) => {
    const url = await client.storage.upload({
      file,
      path: `images/${Date.now()}-${file.name}`
    });

    setPost({ ...post, featuredImage: url });
  };

  return (
    <div className="post-editor">
      <input
        type="text"
        value={post.title}
        onChange={(e) => setPost({ ...post, title: e.target.value })}
        placeholder="Post Title"
      />

      <textarea
        value={post.excerpt}
        onChange={(e) => setPost({ ...post, excerpt: e.target.value })}
        placeholder="Excerpt (optional)"
        maxLength={300}
      />

      <RichTextEditor
        value={post.content}
        onChange={(content) => setPost({ ...post, content })}
      />

      <CategorySelector
        value={post.categoryId}
        onChange={(categoryId) => setPost({ ...post, categoryId })}
      />

      <TagSelector
        value={post.tags}
        onChange={(tags) => setPost({ ...post, tags })}
      />

      <input
        type="file"
        accept="image/*"
        onChange={(e) => uploadImage(e.target.files[0])}
      />

      <label>
        <input
          type="checkbox"
          checked={post.allowComments}
          onChange={(e) => setPost({ ...post, allowComments: e.target.checked })}
        />
        Allow comments
      </label>

      <div className="actions">
        <button onClick={() => savePost(false)}>
          Save Draft
        </button>
        <button onClick={() => savePost(true)}>
          Publish
        </button>
      </div>
    </div>
  );
}

Analytics Dashboard

function AnalyticsDashboard() {
  const [stats, setStats] = useState(null);

  useEffect(() => {
    fetchAnalytics();
  }, []);

  const fetchAnalytics = async () => {
    // Get overview stats
    const [posts, views, comments, users] = await Promise.all([
      client.query('FROM BlogPost SELECT COUNT(*) as total, status'),
      client.query(`
        FROM Analytics
        WHERE type = "pageview" AND createdAt > "${getLastMonth()}"
        GROUP BY DATE(createdAt)
        SELECT DATE(createdAt) as date, COUNT(*) as views
      `),
      client.query('FROM Comment SELECT COUNT(*) as total, status'),
      client.query('FROM User SELECT COUNT(*) as total, role')
    ]);

    // Get top posts
    const topPosts = await client.query(`
      FROM BlogPost
      WHERE status = "published"
      ORDER BY viewCount DESC
      LIMIT 10
    `);

    setStats({
      posts,
      views,
      comments,
      users,
      topPosts
    });
  };

  if (!stats) return <div>Loading...</div>;

  return (
    <div className="analytics-dashboard">
      <h2>Blog Analytics</h2>

      <div className="stats-grid">
        <div className="stat-card">
          <h3>Posts</h3>
          <div className="number">{stats.posts.total}</div>
          <div className="breakdown">
            Published: {stats.posts.published}
            Draft: {stats.posts.draft}
          </div>
        </div>

        <div className="stat-card">
          <h3>Page Views (30 days)</h3>
          <LineChart data={stats.views} />
        </div>

        <div className="stat-card">
          <h3>Comments</h3>
          <div className="number">{stats.comments.total}</div>
          <div className="breakdown">
            Approved: {stats.comments.approved}
            Pending: {stats.comments.pending}
          </div>
        </div>

        <div className="stat-card">
          <h3>Users</h3>
          <div className="number">{stats.users.total}</div>
          <PieChart data={stats.users.byRole} />
        </div>
      </div>

      <div className="top-posts">
        <h3>Top Posts</h3>
        <table>
          <thead>
            <tr>
              <th>Title</th>
              <th>Views</th>
              <th>Likes</th>
              <th>Comments</th>
            </tr>
          </thead>
          <tbody>
            {stats.topPosts.map(post => (
              <tr key={post.id}>
                <td>{post.data.title}</td>
                <td>{post.data.viewCount}</td>
                <td>{post.data.likes}</td>
                <td>{post.commentCount}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

Step 6: API Integration Examples

Next.js API Routes

// pages/api/posts/[slug].js
import { CentraliClient } from '@centrali/sdk';

const client = new CentraliClient({
  workspace: process.env.CENTRALI_WORKSPACE,
  apiKey: process.env.CENTRALI_API_KEY
});

export default async function handler(req, res) {
  const { slug } = req.query;

  if (req.method === 'GET') {
    // Get post with related data
    const result = await client.query(`
      FROM BlogPost
      WHERE slug = "${slug}" AND status = "published"
      INCLUDE author FROM User WHERE id = BlogPost.authorId
      INCLUDE category FROM Category WHERE id = BlogPost.categoryId
      INCLUDE tags FROM Tag WHERE id IN BlogPost.tags
    `);

    if (result.data.length === 0) {
      return res.status(404).json({ error: 'Post not found' });
    }

    // Track view
    await client.functions.execute('trackView', {
      postId: result.data[0].id,
      ipAddress: req.headers['x-forwarded-for'] || req.socket.remoteAddress,
      userAgent: req.headers['user-agent']
    });

    res.status(200).json(result.data[0]);
  }
}

GraphQL Resolver

// GraphQL resolver using Apollo Server
const resolvers = {
  Query: {
    posts: async (_, { limit = 10, offset = 0, status = 'published' }) => {
      const result = await client.query(`
        FROM BlogPost
        WHERE status = "${status}"
        ORDER BY publishedAt DESC
        LIMIT ${limit}
        OFFSET ${offset}
      `);
      return result.data;
    },

    post: async (_, { slug }) => {
      const result = await client.query(`
        FROM BlogPost WHERE slug = "${slug}"
      `);
      return result.data[0];
    },

    searchPosts: async (_, { query }) => {
      const results = await client.search.query({
        index: 'blogposts',
        query,
        filters: 'status = "published"'
      });
      return results.hits;
    }
  },

  Mutation: {
    createPost: async (_, { input }, { user }) => {
      if (!user || user.role !== 'author') {
        throw new Error('Unauthorized');
      }

      const post = await client.records.create({
        structure: 'BlogPost',
        data: {
          ...input,
          authorId: user.id
        }
      });

      return post;
    },

    likePost: async (_, { postId }) => {
      const post = await client.records.get(postId);

      return await client.records.update(postId, {
        likes: post.data.likes + 1
      });
    }
  },

  BlogPost: {
    author: async (post) => {
      return await client.records.get(post.authorId);
    },

    comments: async (post) => {
      const result = await client.query(`
        FROM Comment
        WHERE postId = "${post.id}" AND status = "approved"
        ORDER BY createdAt DESC
      `);
      return result.data;
    }
  }
};

Deployment Checklist

  • [ ] Create all structures in Centrali
  • [ ] Deploy compute functions
  • [ ] Set up triggers
  • [ ] Configure environment variables
  • [ ] Set up search indices
  • [ ] Configure storage for images
  • [ ] Set up email templates
  • [ ] Test moderation workflow
  • [ ] Configure caching strategy
  • [ ] Set up monitoring and alerts
  • [ ] Create admin user accounts
  • [ ] Import initial content
  • [ ] Test RSS feed generation
  • [ ] Verify SEO metadata

Performance Optimizations

  1. Caching Strategy
  2. Cache popular posts for 5 minutes
  3. Cache RSS feed for 1 hour
  4. Cache category/tag lists for 24 hours

  5. Database Queries

  6. Use pagination for all list views
  7. Only select needed fields
  8. Use indexes on slug, status, publishedAt

  9. Image Optimization

  10. Resize images on upload
  11. Generate thumbnails
  12. Use CDN for delivery

  13. Search Optimization

  14. Index only published posts
  15. Update index asynchronously
  16. Cache frequent searches

Security Considerations

  1. Authentication
  2. Implement proper user authentication
  3. Use JWT tokens with expiration
  4. Rate limit login attempts

  5. Authorization

  6. Check user roles for all mutations
  7. Validate ownership for edits
  8. Restrict admin functions

  9. Input Validation

  10. Sanitize all user input
  11. Validate against structure schemas
  12. Prevent XSS in comments

  13. Content Security

  14. Moderate comments automatically
  15. Implement CAPTCHA for submissions
  16. Rate limit API calls

Summary

This blog platform demonstrates how Centrali can power a complete content management system with:

  • Structured data management
  • Automated workflows
  • Real-time search
  • Analytics tracking
  • Content moderation
  • Email notifications
  • API flexibility

The same patterns can be applied to build any content-driven application!