Forum Example
Build a Reddit-style discussion forum with moderation queue and appeals.
Overview
This example demonstrates:
- Topic categories and threads
- Threaded discussions with replies
- Moderation queue for flagged content
- User reporting system
- Moderator dashboard
- Appeal process
Tech Stack
- Frontend: Next.js 14, React, TailwindCSS
- Backend: Next.js API Routes
- Database: PostgreSQL (Prisma)
- Moderation: Vettly SDK
- Authentication: Clerk
Database Schema
prisma
// prisma/schema.prisma
model Topic {
id String @id @default(cuid())
name String
slug String @unique
description String
threads Thread[]
createdAt DateTime @default(now())
}
model Thread {
id String @id @default(cuid())
title String
content String @db.Text
topicId String
topic Topic @relation(fields: [topicId], references: [id])
authorId String
locked Boolean @default(false)
pinned Boolean @default(false)
posts Post[]
moderationSafe Boolean @default(true)
moderationAction String @default("allow")
moderationDecisionId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([topicId])
@@index([authorId])
@@index([createdAt])
}
model Post {
id String @id @default(cuid())
content String @db.Text
threadId String
thread Thread @relation(fields: [threadId], references: [id], onDelete: Cascade)
authorId String
parentId String? // For threaded replies
parent Post? @relation("PostReplies", fields: [parentId], references: [id])
replies Post[] @relation("PostReplies")
moderationSafe Boolean @default(true)
moderationAction String @default("allow")
moderationDecisionId String?
flaggedAt DateTime?
reports Report[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([threadId])
@@index([authorId])
@@index([parentId])
}
model Report {
id String @id @default(cuid())
postId String
post Post @relation(fields: [postId], references: [id])
reportedBy String
reason String
resolved Boolean @default(false)
resolvedBy String?
resolvedAt DateTime?
createdAt DateTime @default(now())
@@index([postId])
@@index([resolved])
}
model ModerationQueue {
id String @id @default(cuid())
contentType String // 'thread' or 'post'
contentId String
content String @db.Text
authorId String
decisionId String
action String
categories Json
status String @default("pending") // pending, approved, removed, appealed
reviewedBy String?
reviewedAt DateTime?
createdAt DateTime @default(now())
@@index([status])
@@index([contentType])
}Create Thread Component
tsx
// components/CreateThread.tsx
'use client'
import { useState } from 'react'
import { ModeratedTextarea } from '@nextauralabs/vettly-react'
import { useRouter } from 'next/navigation'
export default function CreateThread({ topicId }: { topicId: string }) {
const router = useRouter()
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [titleSafe, setTitleSafe] = useState(true)
const [contentSafe, setContentSafe] = useState(true)
const [submitting, setSubmitting] = useState(false)
const canSubmit = titleSafe && contentSafe && title.trim() && content.trim()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!canSubmit) return
setSubmitting(true)
try {
const response = await fetch('/api/threads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
topicId,
title,
content
})
})
const data = await response.json()
if (!response.ok) {
if (data.action === 'flag') {
alert('Your thread has been flagged for review. It will be visible once approved by moderators.')
} else {
alert(data.message || 'Failed to create thread')
}
return
}
router.push(`/threads/${data.thread.id}`)
} catch (error) {
console.error('Error creating thread:', error)
alert('Failed to create thread')
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-bold mb-4">Create New Thread</h2>
<div className="mb-4">
<label className="block text-sm font-medium mb-2">Title</label>
<ModeratedTextarea
apiKey={process.env.NEXT_PUBLIC_VETTLY_API_KEY!}
policyId="moderate"
value={title}
onChange={(value, result) => {
setTitle(value)
setTitleSafe(result.safe)
}}
placeholder="Thread title..."
rows={1}
className="w-full"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-2">Content</label>
<ModeratedTextarea
apiKey={process.env.NEXT_PUBLIC_VETTLY_API_KEY!}
policyId="moderate"
value={content}
onChange={(value, result) => {
setContent(value)
setContentSafe(result.safe)
}}
placeholder="Start your discussion..."
rows={10}
className="w-full"
/>
</div>
<button
type="submit"
disabled={!canSubmit || submitting}
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400"
>
{submitting ? 'Creating...' : 'Create Thread'}
</button>
</form>
)
}Thread View Component
tsx
// components/ThreadView.tsx
'use client'
import { useState } from 'react'
import { ModeratedTextarea } from '@nextauralabs/vettly-react'
import { formatDistanceToNow } from 'date-fns'
interface Thread {
id: string
title: string
content: string
author: { name: string; id: string }
createdAt: string
locked: boolean
posts: Post[]
}
interface Post {
id: string
content: string
author: { name: string; id: string }
moderationAction?: string
replies: Post[]
createdAt: string
}
export default function ThreadView({ thread }: { thread: Thread }) {
const [replyContent, setReplyContent] = useState('')
const [replySafe, setReplySafe] = useState(true)
const [replyingTo, setReplyingTo] = useState<string | null>(null)
const handleReply = async (parentId?: string) => {
if (!replySafe || !replyContent.trim()) return
const response = await fetch(`/api/threads/${thread.id}/posts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: replyContent,
parentId
})
})
const data = await response.json()
if (!response.ok) {
if (data.action === 'flag') {
alert('Your reply has been flagged for review.')
} else {
alert(data.message || 'Failed to post reply')
}
return
}
setReplyContent('')
setReplyingTo(null)
window.location.reload()
}
return (
<div className="max-w-4xl mx-auto">
{/* Thread Header */}
<div className="bg-white rounded-lg shadow p-6 mb-4">
<h1 className="text-2xl font-bold mb-2">{thread.title}</h1>
<div className="text-sm text-gray-600 mb-4">
Posted by {thread.author.name} •{' '}
{formatDistanceToNow(new Date(thread.createdAt), { addSuffix: true })}
</div>
<div className="prose max-w-none">
<p className="whitespace-pre-wrap">{thread.content}</p>
</div>
{thread.locked && (
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded p-3">
🔒 This thread is locked. No new replies can be posted.
</div>
)}
</div>
{/* Reply Form */}
{!thread.locked && (
<div className="bg-white rounded-lg shadow p-6 mb-4">
<h3 className="font-semibold mb-3">Reply to Thread</h3>
<ModeratedTextarea
apiKey={process.env.NEXT_PUBLIC_VETTLY_API_KEY!}
policyId="moderate"
value={replyContent}
onChange={(value, result) => {
setReplyContent(value)
setReplySafe(result.safe)
}}
placeholder="Write your reply..."
rows={5}
/>
<button
onClick={() => handleReply()}
disabled={!replySafe || !replyContent.trim()}
className="mt-3 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400"
>
Post Reply
</button>
</div>
)}
{/* Posts */}
<div className="space-y-4">
{thread.posts
.filter(p => !p.parentId) // Top-level posts only
.map(post => (
<PostCard
key={post.id}
post={post}
onReply={(id) => setReplyingTo(id)}
locked={thread.locked}
/>
))}
</div>
</div>
)
}
function PostCard({
post,
depth = 0,
onReply,
locked
}: {
post: Post
depth?: number
onReply: (id: string) => void
locked: boolean
}) {
const [showReply, setShowReply] = useState(false)
return (
<div
className="bg-white rounded-lg shadow p-4"
style={{ marginLeft: `${depth * 2}rem` }}
>
<div className="flex items-start gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="font-semibold">{post.author.name}</span>
<span className="text-sm text-gray-500">
{formatDistanceToNow(new Date(post.createdAt), { addSuffix: true })}
</span>
{post.moderationAction === 'warn' && (
<span className="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
Flagged
</span>
)}
</div>
<p className="whitespace-pre-wrap mb-3">{post.content}</p>
<div className="flex gap-4 text-sm">
{!locked && (
<button
onClick={() => setShowReply(!showReply)}
className="text-blue-600 hover:text-blue-800"
>
Reply
</button>
)}
<button className="text-gray-600 hover:text-gray-800">
Report
</button>
</div>
{showReply && <ReplyForm postId={post.id} onCancel={() => setShowReply(false)} />}
</div>
</div>
{/* Nested replies */}
{post.replies.length > 0 && (
<div className="mt-4 space-y-4">
{post.replies.map(reply => (
<PostCard
key={reply.id}
post={reply}
depth={depth + 1}
onReply={onReply}
locked={locked}
/>
))}
</div>
)}
</div>
)
}
function ReplyForm({ postId, onCancel }: { postId: string; onCancel: () => void }) {
const [content, setContent] = useState('')
const [safe, setSafe] = useState(true)
return (
<div className="mt-3 border-l-2 border-blue-200 pl-4">
<ModeratedTextarea
apiKey={process.env.NEXT_PUBLIC_VETTLY_API_KEY!}
policyId="moderate"
value={content}
onChange={(value, result) => {
setContent(value)
setSafe(result.safe)
}}
placeholder="Write a reply..."
rows={3}
/>
<div className="mt-2 flex gap-2">
<button
className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
disabled={!safe || !content.trim()}
>
Post
</button>
<button
onClick={onCancel}
className="px-3 py-1 bg-gray-200 text-gray-700 text-sm rounded hover:bg-gray-300"
>
Cancel
</button>
</div>
</div>
)
}Moderation Queue Component
tsx
// app/admin/moderation/page.tsx
'use client'
import { useState, useEffect } from 'react'
export default function ModerationQueue() {
const [queue, setQueue] = useState<any[]>([])
const [filter, setFilter] = useState<'all' | 'pending' | 'appealed'>('pending')
useEffect(() => {
fetchQueue()
}, [filter])
const fetchQueue = async () => {
const response = await fetch(`/api/admin/moderation?status=${filter}`)
const data = await response.json()
setQueue(data.items)
}
const handleReview = async (id: string, action: 'approve' | 'remove') => {
await fetch(`/api/admin/moderation/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action })
})
fetchQueue()
}
return (
<div className="max-w-6xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">Moderation Queue</h1>
{/* Filters */}
<div className="flex gap-2 mb-6">
<button
onClick={() => setFilter('all')}
className={`px-4 py-2 rounded ${
filter === 'all' ? 'bg-blue-600 text-white' : 'bg-gray-200'
}`}
>
All
</button>
<button
onClick={() => setFilter('pending')}
className={`px-4 py-2 rounded ${
filter === 'pending' ? 'bg-blue-600 text-white' : 'bg-gray-200'
}`}
>
Pending ({queue.filter(i => i.status === 'pending').length})
</button>
<button
onClick={() => setFilter('appealed')}
className={`px-4 py-2 rounded ${
filter === 'appealed' ? 'bg-blue-600 text-white' : 'bg-gray-200'
}`}
>
Appealed ({queue.filter(i => i.status === 'appealed').length})
</button>
</div>
{/* Queue Items */}
<div className="space-y-4">
{queue.map(item => (
<div key={item.id} className="bg-white rounded-lg shadow p-6">
<div className="flex justify-between items-start mb-4">
<div>
<span className="text-sm font-medium text-gray-600">
{item.contentType.toUpperCase()}
</span>
<div className="text-sm text-gray-500 mt-1">
Decision ID: {item.decisionId}
</div>
</div>
<span
className={`px-3 py-1 rounded text-sm font-medium ${
item.action === 'block'
? 'bg-red-100 text-red-800'
: item.action === 'flag'
? 'bg-yellow-100 text-yellow-800'
: 'bg-orange-100 text-orange-800'
}`}
>
{item.action.toUpperCase()}
</span>
</div>
<div className="mb-4 p-4 bg-gray-50 rounded">
<p className="whitespace-pre-wrap">{item.content}</p>
</div>
{/* Flagged Categories */}
<div className="mb-4">
<div className="text-sm font-medium mb-2">Flagged Categories:</div>
<div className="flex flex-wrap gap-2">
{JSON.parse(item.categories)
.filter((c: any) => c.triggered)
.map((c: any) => (
<span
key={c.category}
className="px-2 py-1 bg-red-100 text-red-800 text-xs rounded"
>
{c.category} ({(c.score * 100).toFixed(0)}%)
</span>
))}
</div>
</div>
{/* Actions */}
{item.status === 'pending' && (
<div className="flex gap-2">
<button
onClick={() => handleReview(item.id, 'approve')}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
>
Approve
</button>
<button
onClick={() => handleReview(item.id, 'remove')}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Remove
</button>
</div>
)}
</div>
))}
</div>
</div>
)
}API Routes
Create Thread with Moderation Queue
typescript
// app/api/threads/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { ModerationClient } from '@nextauralabs/vettly-sdk'
import { db } from '@/lib/db'
import { auth } from '@clerk/nextjs'
const client = new ModerationClient({
apiKey: process.env.VETTLY_API_KEY!
})
export async function POST(request: NextRequest) {
const { userId } = auth()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { topicId, title, content } = await request.json()
// Check title and content
const [titleResult, contentResult] = await Promise.all([
client.check({
content: title,
policyId: 'moderate',
contentType: 'text',
metadata: { userId, type: 'thread_title' }
}),
client.check({
content,
policyId: 'moderate',
contentType: 'text',
metadata: { userId, type: 'thread_content' }
})
])
const action = titleResult.action === 'block' || contentResult.action === 'block'
? 'block'
: titleResult.action === 'flag' || contentResult.action === 'flag'
? 'flag'
: 'allow'
// Block immediately
if (action === 'block') {
return NextResponse.json(
{
message: 'Thread contains inappropriate content',
categories: [
...titleResult.categories.filter(c => c.triggered),
...contentResult.categories.filter(c => c.triggered)
]
},
{ status: 400 }
)
}
// Create thread
const thread = await db.thread.create({
data: {
title,
content,
topicId,
authorId: userId,
moderationSafe: action === 'allow',
moderationAction: action,
moderationDecisionId: contentResult.decisionId
}
})
// Add to moderation queue if flagged
if (action === 'flag') {
await db.moderationQueue.create({
data: {
contentType: 'thread',
contentId: thread.id,
content: `${title}\n\n${content}`,
authorId: userId,
decisionId: contentResult.decisionId,
action,
categories: JSON.stringify(contentResult.categories)
}
})
return NextResponse.json(
{
thread,
action: 'flag',
message: 'Thread flagged for review'
},
{ status: 202 }
)
}
return NextResponse.json({ thread })
} catch (error) {
console.error('Error creating thread:', error)
return NextResponse.json(
{ message: 'Failed to create thread' },
{ status: 500 }
)
}
}Best Practices
1. Moderation Queue
- Flag borderline content for manual review
- Don't block everything - use 3-tier system (allow/flag/block)
- Track appeal history
2. Threaded Replies
- Check each reply independently
- Consider parent context for nested moderation
- Allow users to report replies
3. Moderator Tools
- Batch operations for efficiency
- Decision history and audit trail
- User behavior tracking