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.
Legacy example under refresh
This tutorial still contains older SDK and query patterns. For current integrations, prefer the Quick Start, SDK Reference, and Collections & Records guides while this example is being updated.
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 collections.
User Collection¶
// POST /workspace/{workspace}/api/v1/collections
{
"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 Collection¶
{
"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 Collection¶
{
"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 Collections¶
// Category Collection
{
"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 Collection
{
"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
async function run() {
const { title } = executionParams;
// 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 api.queryRecords('BlogPost', {
where: {
'data.slug': { eq: finalSlug }
},
page: { limit: 1 }
});
if (existing.data.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
async function run() {
const comment = triggerParams;
// 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 (triggerParams.moderationApiKey) {
try {
const response = await api.httpPost('https://api.moderationapi.com/check', {
text: comment.data.content,
lang: 'en'
}, {
headers: {
'Authorization': `Bearer ${triggerParams.moderationApiKey}`
}
});
if (response.body.flagged) {
isSpam = true;
}
} catch (error) {
api.log('Moderation API error:', error);
}
}
// Update comment status
const status = isSpam ? 'spam' : 'approved';
await api.updateRecord('Comment', comment.id, { status });
// Notify author if approved
if (status === 'approved') {
const post = await api.getRecord('BlogPost', comment.data.postId);
const author = await api.getRecord('User', post.data.authorId);
if (author.data.preferences.emailNotifications) {
// Send notification via webhook or external email service
api.log('Notify author', author.data.email, 'about new comment on', post.data.title);
}
}
return { success: true, data: { status } };
}
View Counter Function¶
Track post views and analytics:
// Function: trackView
async function run() {
const { postId, userId, ipAddress, userAgent } = executionParams;
// Generate a simple view key from post and user/IP
const viewKey = `${postId}-${userId || ipAddress}`;
const recentView = await api.queryRecords('Analytics', {
where: {
'data.key': { eq: viewKey },
createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() }
},
page: { limit: 1 }
});
if (recentView.data.length === 0) {
// Record unique view
await api.createRecord('Analytics', {
type: 'pageview',
key: viewKey,
postId,
userId,
ipAddress,
userAgent,
referrer: executionParams.referrer
});
// Increment view count
const post = await api.getRecord('BlogPost', postId);
await api.updateRecord('BlogPost', postId, {
viewCount: (post.data.viewCount || 0) + 1
});
}
return { success: true };
}
RSS Feed Generator¶
Generate RSS feed for blog posts:
// Function: generateRSSFeed
async function run() {
// Get recent published posts
const posts = await api.queryRecords('BlogPost', {
where: {
'data.status': { eq: 'published' }
},
sort: [{ field: 'data.publishedAt', direction: 'desc' }],
page: { limit: 20 }
});
// Build RSS XML
const rssItems = await Promise.all(posts.data.map(async (post) => {
const author = await api.getRecord('User', 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>
`;
// Upload RSS feed to storage service endpoint
await api.httpPost('https://cdn.example.com/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.queryRecords('BlogPost', {
where: {
'data.status': { eq: 'published' }
},
sort: [{ field: 'data.publishedAt', direction: 'desc' }],
page: { limit: 10, offset: page * 10 }
});
const enrichedPosts = await Promise.all(result.data.map(async (post) => ({
...post,
author: await client.records.get(post.data.authorId),
category: post.data.categoryId ? await client.records.get(post.data.categoryId) : null
})));
setPosts(enrichedPosts);
} 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();
}, [slug]);
const fetchPost = async () => {
const result = await client.queryRecords('BlogPost', {
where: {
'data.slug': { eq: slug }
},
page: { limit: 1 }
});
const currentPost = result.data[0];
if (!currentPost) return;
const author = await client.records.get(currentPost.data.authorId);
const commentResults = await client.queryRecords('Comment', {
where: {
and: [
{ 'data.postId': { eq: currentPost.id } },
{ 'data.status': { eq: 'approved' } }
]
},
sort: [{ field: 'createdAt', direction: 'desc' }],
page: { limit: 50 }
});
const enrichedComments = await Promise.all(commentResults.data.map(async (comment) => ({
...comment,
user: await client.records.get(comment.data.userId)
})));
setPost({
...currentPost,
author
});
setComments(enrichedComments);
trackView(currentPost.id);
};
const trackView = async (postId) => {
await client.functions.execute('trackView', {
postId,
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.records.search('BlogPost', query, {
where: {
'data.status': { eq: 'published' }
},
page: { limit: 20 }
});
setResults(results.data);
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.data.slug}`}>
{hit.data.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 () => {
const [postResults, viewResults, commentResults, userResults] = await Promise.all([
client.queryRecords('BlogPost', {
page: { limit: 1000 }
}),
client.queryRecords('Analytics', {
where: {
and: [
{ 'data.type': { eq: 'pageview' } },
{ createdAt: { gte: getLastMonth() } }
]
},
page: { limit: 5000 }
}),
client.queryRecords('Comment', {
page: { limit: 1000 }
}),
client.queryRecords('User', {
page: { limit: 1000 }
})
]);
const posts = {
total: postResults.data.length,
published: postResults.data.filter(post => post.data.status === 'published').length,
draft: postResults.data.filter(post => post.data.status === 'draft').length
};
const viewsByDate = viewResults.data.reduce((acc, view) => {
const date = new Date(view.createdAt).toISOString().slice(0, 10);
acc[date] = (acc[date] || 0) + 1;
return acc;
}, {});
const views = Object.entries(viewsByDate).map(([date, count]) => ({ date, views: count }));
const comments = {
total: commentResults.data.length,
approved: commentResults.data.filter(comment => comment.data.status === 'approved').length,
pending: commentResults.data.filter(comment => comment.data.status === 'pending').length
};
const usersByRole = userResults.data.reduce((acc, user) => {
const role = user.data.role || 'unknown';
acc[role] = (acc[role] || 0) + 1;
return acc;
}, {});
const users = {
total: userResults.data.length,
byRole: Object.entries(usersByRole).map(([role, value]) => ({ role, value }))
};
const commentCountByPost = commentResults.data.reduce((acc, comment) => {
const postId = comment.data.postId;
acc[postId] = (acc[postId] || 0) + 1;
return acc;
}, {});
const topPosts = postResults.data
.filter(post => post.data.status === 'published')
.sort((a, b) => (b.data.viewCount || 0) - (a.data.viewCount || 0))
.slice(0, 10)
.map(post => ({
...post,
commentCount: commentCountByPost[post.id] || 0
}));
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') {
const result = await client.queryRecords('BlogPost', {
where: {
and: [
{ 'data.slug': { eq: slug } },
{ 'data.status': { eq: 'published' } }
]
},
page: { limit: 1 }
});
if (result.data.length === 0) {
return res.status(404).json({ error: 'Post not found' });
}
const post = result.data[0];
const author = await client.records.get(post.data.authorId);
const category = post.data.categoryId ? await client.records.get(post.data.categoryId) : null;
const tags = await Promise.all((post.data.tags || []).map(tagId => client.records.get(tagId)));
// Track view
await client.functions.execute('trackView', {
postId: post.id,
ipAddress: req.headers['x-forwarded-for'] || req.socket.remoteAddress,
userAgent: req.headers['user-agent']
});
res.status(200).json({
...post,
author,
category,
tags
});
}
}
GraphQL Resolver¶
// GraphQL resolver using Apollo Server
const resolvers = {
Query: {
posts: async (_, { limit = 10, offset = 0, status = 'published' }) => {
const result = await client.queryRecords('BlogPost', {
where: {
'data.status': { eq: status }
},
sort: [{ field: 'data.publishedAt', direction: 'desc' }],
page: { limit, offset }
});
return result.data;
},
post: async (_, { slug }) => {
const result = await client.queryRecords('BlogPost', {
where: {
'data.slug': { eq: slug }
},
page: { limit: 1 }
});
return result.data[0];
},
searchPosts: async (_, { query }) => {
const results = await client.records.search('BlogPost', query, {
where: {
'data.status': { eq: 'published' }
},
page: { limit: 20 }
});
return results.data;
}
},
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.queryRecords('Comment', {
where: {
and: [
{ 'data.postId': { eq: post.id } },
{ 'data.status': { eq: 'approved' } }
]
},
sort: [{ field: 'createdAt', direction: 'desc' }],
page: { limit: 50 }
});
return result.data;
}
}
};
Draft Expiration with TTL¶
Automatically clean up abandoned drafts after 30 days using Record TTL. Set a default TTL on the BlogPost collection so unpublished drafts expire automatically.
Configure Collection Default¶
// Set 30-day TTL on the BlogPost collection
await centrali.collections.update('blogpost-collection-id', {
defaultTtlSeconds: 2592000, // 30 days
});
Create Draft with TTL¶
New drafts inherit the 30-day TTL automatically:
const draft = await centrali.createRecord('BlogPost', {
title: 'Work in Progress',
content: '...',
status: 'draft',
});
console.log(draft.data.expiresAt); // 30 days from now
Publish and Remove TTL¶
When a draft is published, remove the TTL so it lives permanently:
await centrali.updateRecord('BlogPost', draft.id, {
status: 'published',
publishedAt: new Date().toISOString(),
}, { clearTtl: true });
Expired drafts are automatically removed from query results and permanently deleted by the background sweep. See Record TTL for details.
Deployment Checklist¶
- Create all collections in Centrali
- Deploy 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 collection 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!