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¶
- Caching Strategy
- Cache popular posts for 5 minutes
- Cache RSS feed for 1 hour
-
Cache category/tag lists for 24 hours
-
Database Queries
- Use pagination for all list views
- Only select needed fields
-
Use indexes on slug, status, publishedAt
-
Image Optimization
- Resize images on upload
- Generate thumbnails
-
Use CDN for delivery
-
Search Optimization
- Index only published posts
- Update index asynchronously
- Cache frequent searches
Security Considerations¶
- Authentication
- Implement proper user authentication
- Use JWT tokens with expiration
-
Rate limit login attempts
-
Authorization
- Check user roles for all mutations
- Validate ownership for edits
-
Restrict admin functions
-
Input Validation
- Sanitize all user input
- Validate against structure schemas
-
Prevent XSS in comments
-
Content Security
- Moderate comments automatically
- Implement CAPTCHA for submissions
- 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!