Social Feed Example
Build a Twitter/X-style social feed with real-time content moderation.
Try It
Overview
This example shows how to build a complete social media feed with:
- Post creation with text and images
- Real-time moderation feedback
- Comment system
- User profiles
- Reporting and appeals
Tech Stack
- Frontend: Next.js 14 (App Router), React, TailwindCSS
- Backend: Next.js API Routes
- Database: PostgreSQL (via Prisma)
- Moderation: Vettly React components + SDK
Project Structure
social-feed/
├── app/
│ ├── api/
│ │ ├── posts/
│ │ │ ├── route.ts # Create/list posts
│ │ │ └── [id]/
│ │ │ ├── route.ts # Get/delete post
│ │ │ └── comments/
│ │ │ └── route.ts # Post comments
│ │ └── moderate/
│ │ └── route.ts # Moderation endpoint
│ ├── feed/
│ │ └── page.tsx # Main feed page
│ └── profile/
│ └── [userId]/
│ └── page.tsx # User profile
├── components/
│ ├── CreatePost.tsx # Post creation form
│ ├── PostCard.tsx # Individual post
│ ├── CommentSection.tsx # Comments
│ └── ModerationBadge.tsx # Moderation status
├── lib/
│ ├── db.ts # Database client
│ └── moderation.ts # Moderation utilities
└── prisma/
└── schema.prisma # Database schemaDatabase Schema
prisma
// prisma/schema.prisma
model User {
id String @id @default(cuid())
email String @unique
name String
avatar String?
posts Post[]
comments Comment[]
createdAt DateTime @default(now())
}
model Post {
id String @id @default(cuid())
content String
imageUrl String?
authorId String
author User @relation(fields: [authorId], references: [id])
comments Comment[]
moderationSafe Boolean @default(true)
moderationAction String? @default("allow")
moderationDecisionId String?
flaggedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
@@index([createdAt])
}
model Comment {
id String @id @default(cuid())
content String
postId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
authorId String
author User @relation(fields: [authorId], references: [id])
moderationSafe Boolean @default(true)
moderationDecisionId String?
createdAt DateTime @default(now())
@@index([postId])
@@index([authorId])
}Post Creation Component
tsx
// components/CreatePost.tsx
'use client'
import { useState } from 'react'
import { ModeratedTextarea, ModeratedImageUpload } from '@nextauralabs/vettly-react'
import '@nextauralabs/vettly-react/styles.css'
export default function CreatePost({ userId }: { userId: string }) {
const [content, setContent] = useState('')
const [imageUrl, setImageUrl] = useState<string | null>(null)
const [contentSafe, setContentSafe] = useState(true)
const [imageSafe, setImageSafe] = useState(true)
const [posting, setPosting] = useState(false)
const canPost = contentSafe && imageSafe && content.trim().length > 0
const handlePost = async () => {
if (!canPost) return
setPosting(true)
try {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content,
imageUrl
})
})
if (!response.ok) {
const error = await response.json()
alert(error.message || 'Failed to create post')
return
}
// Reset form
setContent('')
setImageUrl(null)
// Refresh feed (or use optimistic updates)
window.location.reload()
} catch (error) {
console.error('Error creating post:', error)
alert('Failed to create post')
} finally {
setPosting(false)
}
}
return (
<div className="bg-white rounded-lg shadow p-4 mb-4">
<h2 className="text-lg font-semibold mb-4">Create Post</h2>
<ModeratedTextarea
apiKey={process.env.NEXT_PUBLIC_VETTLY_API_KEY!}
policyId="social_media"
value={content}
onChange={(value, result) => {
setContent(value)
setContentSafe(result.safe)
}}
placeholder="What's happening?"
rows={4}
className="w-full"
/>
<div className="mt-4">
<ModeratedImageUpload
apiKey={process.env.NEXT_PUBLIC_VETTLY_API_KEY!}
policyId="social_media"
onUpload={async (file, result) => {
if (!result.safe) {
alert('Image contains inappropriate content')
setImageSafe(false)
return
}
setImageSafe(true)
// Upload to your storage
const formData = new FormData()
formData.append('image', file)
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
})
const { url } = await response.json()
setImageUrl(url)
}}
/>
</div>
{imageUrl && (
<div className="mt-2 relative">
<img
src={imageUrl}
alt="Upload preview"
className="rounded-lg max-h-60"
/>
<button
onClick={() => setImageUrl(null)}
className="absolute top-2 right-2 bg-black bg-opacity-50 text-white rounded-full p-1"
>
✕
</button>
</div>
)}
<div className="flex justify-between items-center mt-4">
<div className="text-sm text-gray-500">
{content.length}/280 characters
</div>
<button
onClick={handlePost}
disabled={!canPost || posting}
className={`px-6 py-2 rounded-full font-semibold ${
canPost && !posting
? 'bg-blue-500 text-white hover:bg-blue-600'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
>
{posting ? 'Posting...' : 'Post'}
</button>
</div>
</div>
)
}Feed Display Component
tsx
// components/PostCard.tsx
'use client'
import { useState } from 'react'
import { formatDistanceToNow } from 'date-fns'
interface Post {
id: string
content: string
imageUrl?: string
author: {
name: string
avatar?: string
}
moderationSafe: boolean
moderationAction?: string
createdAt: string
_count: {
comments: number
}
}
export default function PostCard({ post }: { post: Post }) {
const [showComments, setShowComments] = useState(false)
return (
<div className="bg-white rounded-lg shadow p-4 mb-4">
{/* Header */}
<div className="flex items-center mb-3">
<img
src={post.author.avatar || '/default-avatar.png'}
alt={post.author.name}
className="w-10 h-10 rounded-full mr-3"
/>
<div>
<div className="font-semibold">{post.author.name}</div>
<div className="text-sm text-gray-500">
{formatDistanceToNow(new Date(post.createdAt), { addSuffix: true })}
</div>
</div>
{post.moderationAction === 'warn' && (
<div className="ml-auto">
<span className="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
Flagged Content
</span>
</div>
)}
</div>
{/* Content */}
<div className="mb-3">
<p className="whitespace-pre-wrap">{post.content}</p>
</div>
{/* Image */}
{post.imageUrl && (
<img
src={post.imageUrl}
alt="Post image"
className="rounded-lg w-full mb-3"
/>
)}
{/* Actions */}
<div className="flex items-center gap-6 text-gray-500">
<button
onClick={() => setShowComments(!showComments)}
className="flex items-center gap-2 hover:text-blue-500"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<span>{post._count.comments}</span>
</button>
<button className="flex items-center gap-2 hover:text-green-500">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button className="flex items-center gap-2 hover:text-red-500">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</button>
</div>
{/* Comments */}
{showComments && (
<CommentSection postId={post.id} />
)}
</div>
)
}Comment Section Component
tsx
// components/CommentSection.tsx
'use client'
import { useState, useEffect } from 'react'
import { ModeratedTextarea } from '@nextauralabs/vettly-react'
export default function CommentSection({ postId }: { postId: string }) {
const [comments, setComments] = useState<any[]>([])
const [newComment, setNewComment] = useState('')
const [commentSafe, setCommentSafe] = useState(true)
const [posting, setPosting] = useState(false)
useEffect(() => {
fetchComments()
}, [postId])
const fetchComments = async () => {
const response = await fetch(`/api/posts/${postId}/comments`)
const data = await response.json()
setComments(data.comments)
}
const handlePostComment = async () => {
if (!commentSafe || !newComment.trim()) return
setPosting(true)
try {
const response = await fetch(`/api/posts/${postId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newComment })
})
if (!response.ok) {
const error = await response.json()
alert(error.message || 'Failed to post comment')
return
}
setNewComment('')
fetchComments()
} catch (error) {
console.error('Error posting comment:', error)
alert('Failed to post comment')
} finally {
setPosting(false)
}
}
return (
<div className="mt-4 border-t pt-4">
{/* Comment Input */}
<div className="mb-4">
<ModeratedTextarea
apiKey={process.env.NEXT_PUBLIC_VETTLY_API_KEY!}
policyId="social_media"
value={newComment}
onChange={(value, result) => {
setNewComment(value)
setCommentSafe(result.safe)
}}
placeholder="Write a comment..."
rows={2}
className="w-full"
/>
<button
onClick={handlePostComment}
disabled={!commentSafe || !newComment.trim() || posting}
className="mt-2 px-4 py-2 bg-blue-500 text-white rounded disabled:bg-gray-300"
>
{posting ? 'Posting...' : 'Comment'}
</button>
</div>
{/* Comments List */}
<div className="space-y-3">
{comments.map((comment) => (
<div key={comment.id} className="flex gap-3">
<img
src={comment.author.avatar || '/default-avatar.png'}
alt={comment.author.name}
className="w-8 h-8 rounded-full"
/>
<div className="flex-1 bg-gray-50 rounded-lg p-3">
<div className="font-semibold text-sm">{comment.author.name}</div>
<p className="text-sm mt-1">{comment.content}</p>
</div>
</div>
))}
</div>
</div>
)
}API Routes
Create Post
typescript
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { ModerationClient } from '@nextauralabs/vettly-sdk'
import { db } from '@/lib/db'
const client = new ModerationClient({
apiKey: process.env.VETTLY_API_KEY!
})
export async function POST(request: NextRequest) {
try {
const { content, imageUrl, userId } = await request.json()
// Check text content
const textResult = await client.check({
content,
policyId: 'social_media',
contentType: 'text',
metadata: { userId }
})
// Check image if provided
let imageResult = null
if (imageUrl) {
// Fetch image and convert to base64
const imageResponse = await fetch(imageUrl)
const imageBuffer = await imageResponse.arrayBuffer()
const base64 = Buffer.from(imageBuffer).toString('base64')
imageResult = await client.check({
content: base64,
policyId: 'social_media',
contentType: 'image',
metadata: { userId }
})
}
// Block if either is unsafe
const safe = textResult.safe && (!imageResult || imageResult.safe)
const action = textResult.action === 'block' || imageResult?.action === 'block'
? 'block'
: textResult.action
if (action === 'block') {
return NextResponse.json(
{
message: 'Post contains inappropriate content',
categories: [
...textResult.categories.filter(c => c.triggered),
...(imageResult?.categories.filter(c => c.triggered) || [])
]
},
{ status: 400 }
)
}
// Create post
const post = await db.post.create({
data: {
content,
imageUrl,
authorId: userId,
moderationSafe: safe,
moderationAction: action,
moderationDecisionId: textResult.decisionId,
flaggedAt: action === 'warn' || action === 'flag' ? new Date() : null
},
include: {
author: true,
_count: {
select: { comments: true }
}
}
})
return NextResponse.json({ post })
} catch (error) {
console.error('Error creating post:', error)
return NextResponse.json(
{ message: 'Failed to create post' },
{ status: 500 }
)
}
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get('limit') || '20')
const offset = parseInt(searchParams.get('offset') || '0')
const posts = await db.post.findMany({
take: limit,
skip: offset,
orderBy: { createdAt: 'desc' },
include: {
author: true,
_count: {
select: { comments: true }
}
}
})
return NextResponse.json({ posts })
}Add Comment
typescript
// app/api/posts/[id]/comments/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { ModerationClient } from '@nextauralabs/vettly-sdk'
import { db } from '@/lib/db'
const client = new ModerationClient({
apiKey: process.env.VETTLY_API_KEY!
})
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { content, userId } = await request.json()
// Check comment
const result = await client.check({
content,
policyId: 'social_media',
contentType: 'text',
metadata: {
userId,
postId: params.id,
type: 'comment'
}
})
if (result.action === 'block') {
return NextResponse.json(
{ message: 'Comment contains inappropriate content' },
{ status: 400 }
)
}
// Create comment
const comment = await db.comment.create({
data: {
content,
postId: params.id,
authorId: userId,
moderationSafe: result.safe,
moderationDecisionId: result.decisionId
},
include: {
author: true
}
})
return NextResponse.json({ comment })
} catch (error) {
console.error('Error creating comment:', error)
return NextResponse.json(
{ message: 'Failed to create comment' },
{ status: 500 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const comments = await db.comment.findMany({
where: { postId: params.id },
orderBy: { createdAt: 'asc' },
include: {
author: true
}
})
return NextResponse.json({ comments })
}Key Features
1. Real-time Moderation
- Posts and comments checked before saving
- Visual feedback while typing
- Prevents submission of unsafe content
2. Multi-modal Support
- Text content moderation
- Image content moderation
- Combined validation
3. User Experience
- Non-blocking for warnings
- Clear feedback on violations
- Smooth UX with loading states
4. Audit Trail
- Store
moderationDecisionIdfor all content - Track flagged content
- Enable appeals and review
Best Practices
1. Server-Side Verification
Always verify moderation server-side, even if client-side check passed:
typescript
// Don't trust client-only checks
const result = await client.check({
content,
policyId: 'social_media',
contentType: 'text'
})
if (!result.safe) {
return NextResponse.json({ error: 'Blocked' }, { status: 403 })
}2. Store Decision IDs
typescript
await db.post.create({
data: {
content,
moderationDecisionId: result.decisionId // ✅ Important for audit
}
})3. Handle Errors Gracefully
typescript
try {
const result = await client.check(...)
} catch (error) {
// Decide: fail open or closed?
if (process.env.NODE_ENV === 'production') {
// Fail open in production to avoid blocking legitimate users
logger.error('Moderation failed', error)
} else {
throw error
}
}