Full-Stack Development
Blog Tutorial
Build a complete blog platform end-to-end — content modeling, post listing, detail pages, categories, and SEO.
What You'll Build
A blog with:
- Post list with pagination
- Post detail with Markdown rendering
- Category filtering
- Tag browsing
- SEO-friendly URLs (slug-based)
- Cover images
Step 1 — Define Content Types
Create two content types — one for categories, one for posts:
name = "Category"
table = "blog_categories"
plural = "categories"
[fields.name]
type = "text"
required = true
unique = true
[fields.slug]
type = "text"
required = true
unique = true
[fields.description]
type = "text"name = "Post"
table = "blog_posts"
plural = "posts"
[fields.title]
type = "text"
required = true
[fields.slug]
type = "text"
required = true
unique = true
[fields.body]
type = "rich_text"
[fields.excerpt]
type = "text"
max_length = 300
[fields.cover_image]
type = "media"
accept = "image/*"
[fields.category]
type = "relation"
related_type = "categories"
relation = "many_to_one"
[fields.tags]
type = "relation"
related_type = "tags"
relation = "many_to_many"
[fields.status]
type = "enum"
options = ["draft", "published"]
default = "draft"
[fields.published_at]
type = "timestamp"Restart the server to create the tables.
Step 2 — Create Sample Data
# Create a category
curl -X POST http://localhost:9898/api/v1/admin/cms/categories \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Technology","slug":"technology","description":"Tech articles"}'
# Create a post
curl -X POST http://localhost:9898/api/v1/admin/cms/posts \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Getting Started with RaisFast",
"slug": "getting-started",
"body": "## Welcome\n\nThis is my first post...",
"status": "published",
"published_at": "2025-01-15T10:00:00Z"
}'Step 3 — Frontend: Post List Page
import { client } from "@/lib/client";
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
interface Post {
id: string;
title: string;
slug: string;
excerpt?: string;
cover_image?: string;
published_at: string;
}
export function PostList() {
const [posts, setPosts] = useState<Post[]>([]);
const [page, setPage] = useState(1);
useEffect(() => {
client.contentTypes
.list("posts", {
page,
limit: 10,
status: "published",
sort: "published_at",
order: "desc",
})
.then((res) => setPosts(res.data));
}, [page]);
return (
<div className="max-w-4xl mx-auto py-8">
<h1 className="text-3xl font-bold mb-8">Blog</h1>
<div className="space-y-6">
{posts.map((post) => (
<Link
key={post.id}
to={`/blog/${post.slug}`}
className="block p-6 border rounded-lg hover:shadow-md transition"
>
{post.cover_image && (
<img src={post.cover_image} alt={post.title} className="mb-4 rounded" />
)}
<h2 className="text-xl font-semibold">{post.title}</h2>
{post.excerpt && (
<p className="text-gray-600 mt-2">{post.excerpt}</p>
)}
<time className="text-sm text-gray-400 mt-2 block">
{new Date(post.published_at).toLocaleDateString()}
</time>
</Link>
))}
</div>
<div className="flex gap-4 mt-8">
{page > 1 && (
<button onClick={() => setPage(page - 1)}>Previous</button>
)}
<button onClick={() => setPage(page + 1)}>Next</button>
</div>
</div>
);
}Step 4 — Frontend: Post Detail Page
import { client } from "@/lib/client";
import { useState, useEffect } from "react";
import { useParams, Link } from "react-router-dom";
interface Post {
id: string;
title: string;
slug: string;
body: string;
published_at: string;
}
export function PostDetail() {
const { slug } = useParams();
const [post, setPost] = useState<Post | null>(null);
useEffect(() => {
if (!slug) return;
client.contentTypes
.list("posts", { slug, status: "published", limit: 1 })
.then((res) => {
if (res.data.length > 0) setPost(res.data[0]);
});
}, [slug]);
if (!post) return <div>Loading...</div>;
return (
<article className="max-w-4xl mx-auto py-8">
<Link to="/blog" className="text-sm text-gray-500 mb-4 block">
← Back to Blog
</Link>
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<time className="text-gray-400 block mb-8">
{new Date(post.published_at).toLocaleDateString()}
</time>
<div
className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: post.body }}
/>
</article>
);
}Step 5 — Category Filtering
export function CategoryFilter({ onFilter }) {
const [categories, setCategories] = useState([]);
useEffect(() => {
client.contentTypes.list("categories", { limit: 100 }).then((res) => {
setCategories(res.data);
});
}, []);
return (
<div className="flex gap-2 mb-8">
<button onClick={() => onFilter(null)} className="px-3 py-1 rounded border">
All
</button>
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => onFilter(cat.id)}
className="px-3 py-1 rounded border"
>
{cat.name}
</button>
))}
</div>
);
}Pass the category filter to the post list:
client.contentTypes.list("posts", {
page: 1,
limit: 10,
status: "published",
category: selectedCategoryId, // filter by relation
sort: "published_at",
order: "desc",
});SEO Tips
- Use slug-based URLs (
/blog/getting-started) instead of IDs - Set
<title>and<meta description>from the post's title and excerpt - Add Open Graph tags for social sharing
- Generate a sitemap from the posts API
Next Step
Learn to build an E-commerce Store with products, cart, and payments.
