Build a content moderation tool in Lovable with a review queue DataTable, full-content Dialog, moderator stats dashboard, and trend charts. Uses Supabase for the moderation queue, rules, and moderator performance tracking. Optionally adds AI pre-screening via a Supabase Edge Function that calls an external moderation API before items enter the queue.
What you're building
A moderation operations dashboard designed for fast, accurate review of flagged content items.
The review queue is the core of the tool — a DataTable where each row is a content item awaiting a decision. Moderators can use keyboard shortcuts (A to approve, R to reject, S to skip) to process items at speed without clicking. Selecting a row opens a Dialog with the full content on the left and a decision panel on the right, showing previous flags, AI pre-screen score, and rule violations.
Admins configure moderation rules — keyword lists, content type thresholds, and escalation triggers — via a separate rules editor. A stats section shows each moderator's daily volume, accuracy rate, and average decision time. Charts break down moderation trends by category and decision type over configurable time periods.
Final result
A fully operational content moderation platform where moderators process a queue with keyboard shortcuts, admins configure rules and review trends via charts, and an optional Edge Function pre-screens content with AI before it enters the human review queue.
Tech stack
Prerequisites
- Supabase project with Auth enabled and at least one moderator user account
- Lovable Pro account for Edge Function deployment
- SUPABASE_URL and SUPABASE_ANON_KEY added to Lovable Cloud tab → Secrets
- If using AI pre-screening: an API key for a moderation service (e.g., OpenAI Moderation API, which is free)
Build steps
Create the Supabase schema for moderation
Set up three tables: moderation_items (the queue), moderation_rules (configuration), and moderator_stats (performance tracking). Include a status enum and a trigger to update stats on every decision.
1-- Run in Supabase SQL Editor2create table moderation_items (3 id uuid primary key default gen_random_uuid(),4 content_type text not null check (content_type in ('post','comment','image','video','profile')),5 content_text text,6 content_url text,7 source_user_id text,8 source_platform text,9 ai_score numeric(3,2),10 ai_flags jsonb default '[]',11 status text default 'pending' check (status in ('pending','approved','rejected','escalated','skipped')),12 reviewed_by uuid references auth.users(id),13 reviewed_at timestamptz,14 review_note text,15 priority int default 0,16 submitted_at timestamptz default now()17);1819create table moderation_rules (20 id uuid primary key default gen_random_uuid(),21 rule_name text not null,22 rule_type text check (rule_type in ('keyword','threshold','pattern')),23 rule_value text not null,24 action text default 'flag' check (action in ('flag','auto_reject','escalate')),25 is_active boolean default true,26 created_at timestamptz default now()27);2829create table moderator_stats (30 id uuid primary key default gen_random_uuid(),31 moderator_id uuid references auth.users(id) unique,32 total_reviewed int default 0,33 total_approved int default 0,34 total_rejected int default 0,35 avg_decision_seconds numeric(6,1),36 updated_at timestamptz default now()37);3839create index idx_items_status_submitted on moderation_items(status, submitted_at);40create index idx_items_priority on moderation_items(priority desc, submitted_at asc);4142alter table moderation_items enable row level security;43alter table moderation_rules enable row level security;44alter table moderator_stats enable row level security;4546create policy "Moderators read queue" on moderation_items for select using (auth.role() = 'authenticated');47create policy "Moderators update queue" on moderation_items for update using (auth.role() = 'authenticated');48create policy "Moderators read rules" on moderation_rules for select using (auth.role() = 'authenticated');49create policy "Moderators read stats" on moderator_stats for select using (auth.role() = 'authenticated');Pro tip: Add a priority column (0=normal, 1=high, 2=urgent) and sort the queue by priority DESC, submitted_at ASC. This puts escalated or AI-flagged items at the top automatically.
Expected result: Three tables appear in Supabase Table Editor. Indexes are created on the moderation_items table. RLS is active on all tables.
Build the moderation queue DataTable with keyboard shortcuts
Prompt Lovable to create the main queue view with a keyboard shortcut hook. When a row is focused, pressing A approves, R rejects, and S skips the item without mouse interaction.
1Build the moderation queue at /queue.23Fetch pending moderation_items ordered by priority DESC, submitted_at ASC (oldest high-priority first).45Render a shadcn DataTable with columns:6- submitted_at (relative time: '2 hours ago')7- content_type Badge (post=blue, comment=gray, image=purple, video=orange, profile=teal)8- content_text (truncated to 80 chars)9- ai_score: colored badge (green < 0.3, yellow 0.3-0.7, red > 0.7) or 'Not scored' if null10- priority Badge (high=red, normal=gray)11- status Badge1213Add a selected row state. Clicking a row selects it and opens the ReviewDialog.1415Add a keyboard shortcut hook:16- When a row is selected:17 - Press 'A' → approve (update status to 'approved', reviewed_by to current user)18 - Press 'R' → reject (update status to 'rejected')19 - Press 'S' → skip (update status to 'skipped')20 - Press arrow keys → move selection up/down21- Show a keyboard shortcuts legend below the table2223Add filter tabs above the table: All | Pending | Escalated | High Priority24Show item count per tab as a Badge.Expected result: The queue DataTable loads with pending items. Arrow keys change which row is selected. Pressing A or R immediately updates the item status in Supabase and the row disappears from the Pending tab.
Build the review Dialog with decision panel
The review Dialog shows full content on the left and a structured decision panel on the right with AI flags, rule violations, and approve/reject/escalate buttons with a note field.
1import { useState } from 'react'2import { Dialog, DialogContent } from '@/components/ui/dialog'3import { Badge } from '@/components/ui/badge'4import { Button } from '@/components/ui/button'5import { Textarea } from '@/components/ui/textarea'6import { Alert, AlertDescription } from '@/components/ui/alert'7import { supabase } from '@/lib/supabase'8import { toast } from 'sonner'910interface ModerationItem {11 id: string12 content_type: string13 content_text: string | null14 content_url: string | null15 source_user_id: string | null16 ai_score: number | null17 ai_flags: string[]18 priority: number19}2021interface ReviewDialogProps {22 item: ModerationItem | null23 onDecision: (id: string, status: string) => void24 onClose: () => void25}2627export function ReviewDialog({ item, onDecision, onClose }: ReviewDialogProps) {28 const [note, setNote] = useState('')29 const [loading, setLoading] = useState(false)3031 const decide = async (status: 'approved' | 'rejected' | 'escalated') => {32 if (!item) return33 setLoading(true)34 const { error } = await supabase.from('moderation_items').update({35 status,36 review_note: note || null,37 reviewed_at: new Date().toISOString()38 }).eq('id', item.id)39 setLoading(false)40 if (error) { toast.error('Failed to save decision'); return }41 toast.success(`Item ${status}`)42 onDecision(item.id, status)43 setNote('')44 onClose()45 }4647 const scoreColor = (score: number | null) => {48 if (score === null) return 'bg-gray-100 text-gray-600'49 if (score < 0.3) return 'bg-green-100 text-green-700'50 if (score < 0.7) return 'bg-yellow-100 text-yellow-700'51 return 'bg-red-100 text-red-700'52 }5354 return (55 <Dialog open={!!item} onOpenChange={onClose}>56 <DialogContent className="max-w-4xl">57 <div className="grid grid-cols-2 gap-6 h-[500px]">58 <div className="overflow-auto space-y-3">59 <div className="flex gap-2 items-center">60 <Badge variant="outline">{item?.content_type}</Badge>61 {item?.priority === 1 && <Badge variant="destructive">High Priority</Badge>}62 </div>63 {item?.content_text && (64 <div className="bg-muted rounded p-4 text-sm leading-relaxed">{item.content_text}</div>65 )}66 {item?.content_url && (67 <img src={item.content_url} alt="Content" className="rounded max-h-64 object-contain" />68 )}69 <p className="text-xs text-muted-foreground">Source user: {item?.source_user_id ?? 'anonymous'}</p>70 </div>71 <div className="space-y-4">72 <div>73 <p className="text-sm font-medium mb-2">AI Pre-screen Score</p>74 <span className={`px-3 py-1 rounded-full text-sm font-medium ${scoreColor(item?.ai_score ?? null)}`}>75 {item?.ai_score !== null ? (item?.ai_score! * 100).toFixed(0) + '%' : 'Not scored'}76 </span>77 </div>78 {item?.ai_flags && item.ai_flags.length > 0 && (79 <div>80 <p className="text-sm font-medium mb-2">Flagged Categories</p>81 <div className="flex flex-wrap gap-2">82 {item.ai_flags.map(f => <Badge key={f} variant="destructive">{f}</Badge>)}83 </div>84 </div>85 )}86 <div>87 <p className="text-sm font-medium mb-2">Moderator Note (optional)</p>88 <Textarea value={note} onChange={e => setNote(e.target.value)}89 placeholder="Add context for this decision..." rows={3} />90 </div>91 <div className="flex gap-2 pt-2">92 <Button className="flex-1 bg-green-600 hover:bg-green-700" onClick={() => decide('approved')} disabled={loading}>Approve</Button>93 <Button className="flex-1" variant="destructive" onClick={() => decide('rejected')} disabled={loading}>Reject</Button>94 <Button className="flex-1" variant="outline" onClick={() => decide('escalated')} disabled={loading}>Escalate</Button>95 </div>96 </div>97 </div>98 </DialogContent>99 </Dialog>100 )101}Expected result: The Dialog opens with content on the left and the AI score, flags, note field, and decision buttons on the right. All three decision buttons update Supabase and close the Dialog.
Add moderator stats and trend charts
The stats dashboard shows personal metrics for the logged-in moderator and team-wide trend charts. Charts use Recharts (included in shadcn) to show moderation volume and decision breakdown over time.
1Build a /stats page with two sections:231. Personal Stats (top)4Fetch from moderator_stats where moderator_id = current user:5- Four Cards: Total Reviewed, Approved Rate, Rejected Rate, Avg Decision Time6- A sparkline chart (BarChart from recharts) showing the last 7 days of decisions782. Team Trends (bottom)9Fetch last 30 days of moderation_items grouped by date and status.10Render two charts side by side using shadcn Chart (recharts):1112a) Line chart: volume per day (x=date, y=count, separate lines for approved/rejected/escalated)13b) Bar chart: breakdown by content_type (x=type, y=count, stacked by status)1415Above the charts add a DateRangePicker (two Popover Calendars) to filter the date range.16Fetching data should re-query Supabase when the date range changes.1718All charts must be responsive (100% width, use ResponsiveContainer).19Use muted colors for approved, red for rejected, orange for escalated.Pro tip: Tell Lovable to fetch chart data using a Supabase SQL query with date_trunc('day', submitted_at) so grouping happens in the database rather than in JavaScript — much faster for large datasets.
Expected result: The stats page shows personal metrics and team trend charts. Changing the date range re-fetches and re-renders both charts. The volume line chart shows daily moderation activity.
Add the AI pre-screening Edge Function
An Edge Function receives new content items and calls the OpenAI Moderation API (free endpoint). It scores the content and updates the moderation_items row with ai_score and ai_flags before the moderator sees it.
1// supabase/functions/prescreen-content/index.ts2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'45const corsHeaders = {6 'Access-Control-Allow-Origin': '*',7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type'8}910serve(async (req) => {11 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })1213 const { item_id, text } = await req.json()14 if (!item_id || !text) {15 return new Response(JSON.stringify({ error: 'item_id and text required' }), { status: 400, headers: corsHeaders })16 }1718 const openaiKey = Deno.env.get('OPENAI_API_KEY')19 if (!openaiKey) return new Response(JSON.stringify({ error: 'Missing OPENAI_API_KEY' }), { status: 500, headers: corsHeaders })2021 const modRes = await fetch('https://api.openai.com/v1/moderations', {22 method: 'POST',23 headers: { 'Authorization': `Bearer ${openaiKey}`, 'Content-Type': 'application/json' },24 body: JSON.stringify({ input: text })25 })2627 const modData = await modRes.json()28 const result = modData.results?.[0]29 if (!result) return new Response(JSON.stringify({ error: 'No moderation result' }), { status: 500, headers: corsHeaders })3031 const flags = Object.entries(result.categories as Record<string, boolean>)32 .filter(([, v]) => v)33 .map(([k]) => k)3435 const supabase = createClient(36 Deno.env.get('SUPABASE_URL')!,37 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!38 )3940 await supabase.from('moderation_items').update({41 ai_score: result.category_scores ? Math.max(...Object.values(result.category_scores as Record<string, number>)) : null,42 ai_flags: flags,43 priority: flags.length > 0 ? 1 : 044 }).eq('id', item_id)4546 return new Response(JSON.stringify({ flags, flagged: result.flagged }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } })47})Pro tip: After deploying, add OPENAI_API_KEY to Lovable Cloud tab → Secrets. Then prompt Lovable: 'After inserting a new moderation item, call the prescreen-content Edge Function with the item_id and content_text. Update the row's ai_score and ai_flags fields when the function responds.'
Expected result: New items submitted to the queue are automatically scored by OpenAI's Moderation API. The ai_score and ai_flags columns populate within seconds. High-risk items appear with a priority=1 badge at the top of the queue.
Complete code
1import { useEffect, useCallback } from 'react'2import { supabase } from '@/lib/supabase'3import { toast } from 'sonner'45type ModerationStatus = 'approved' | 'rejected' | 'skipped' | 'escalated'67interface UseKeyboardModerationOptions {8 selectedItemId: string | null9 onDecision: (id: string, status: ModerationStatus) => void10 onNavigate: (direction: 'up' | 'down') => void11}1213export function useKeyboardModeration({ selectedItemId, onDecision, onNavigate }: UseKeyboardModerationOptions) {14 const applyDecision = useCallback(async (status: ModerationStatus) => {15 if (!selectedItemId) return16 const { error } = await supabase.from('moderation_items').update({17 status,18 reviewed_at: new Date().toISOString()19 }).eq('id', selectedItemId)20 if (error) {21 toast.error('Failed to apply decision')22 return23 }24 const labels: Record<ModerationStatus, string> = {25 approved: 'Approved',26 rejected: 'Rejected',27 skipped: 'Skipped',28 escalated: 'Escalated'29 }30 toast.success(labels[status])31 onDecision(selectedItemId, status)32 }, [selectedItemId, onDecision])3334 useEffect(() => {35 const handler = (e: KeyboardEvent) => {36 if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return37 switch (e.key.toLowerCase()) {38 case 'a': applyDecision('approved'); break39 case 'r': applyDecision('rejected'); break40 case 's': applyDecision('skipped'); break41 case 'e': applyDecision('escalated'); break42 case 'arrowdown': e.preventDefault(); onNavigate('down'); break43 case 'arrowup': e.preventDefault(); onNavigate('up'); break44 }45 }46 window.addEventListener('keydown', handler)47 return () => window.removeEventListener('keydown', handler)48 }, [applyDecision, onNavigate])49}Customization ideas
Rule-based auto-rejection
Add a Supabase trigger that reads active moderation_rules and auto-rejects items matching keyword patterns before they reach the human queue. Items auto-rejected by rules get a special 'auto_rejected' status.
Appeals workflow
Add an appeals table where users can contest a rejection. Show an Appeals tab in the queue DataTable and let senior moderators override decisions.
Batch processing mode
Add a checkbox column to the DataTable for multi-select. A bulk actions bar appears when items are selected, allowing approve-all or reject-all operations in one Supabase update call.
Moderator shift reporting
At the end of a shift, generate a summary PDF of decisions made, average response time, and items processed. Use browser print API triggered by a Button in the stats page.
Real-time queue updates
Enable Supabase Realtime on moderation_items so new submissions appear at the top of the queue instantly without refreshing — critical for high-volume platforms.
Common pitfalls
Pitfall: Using the service role key in the frontend moderation UI
How to avoid: Only the Edge Function (prescreen-content) should use SUPABASE_SERVICE_ROLE_KEY. All frontend Supabase calls use the anon key with proper RLS policies.
Pitfall: Forgetting to disable keyboard shortcuts when a text field is focused
How to avoid: The useKeyboardModeration hook in the complete_code section includes the check: if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return.
Pitfall: Fetching chart data in JavaScript after loading all rows
How to avoid: Use a Supabase SQL query with date_trunc and GROUP BY to aggregate data in the database. Return only the grouped rows to the frontend.
Pitfall: Not setting reviewed_by when a decision is made via keyboard shortcuts
How to avoid: Always include reviewed_by: session.user.id in the update payload alongside the status change.
Best practices
- Always record reviewed_by and reviewed_at on every moderation decision for a complete audit trail
- Use a priority column on moderation_items and sort by priority DESC so AI-flagged high-risk items surface first
- Implement keyboard shortcuts for approve/reject/skip — experienced moderators can process 2-3x more items per hour
- Aggregate chart data with SQL GROUP BY rather than loading raw rows into JavaScript for large queues
- Enable Supabase Realtime on moderation_items so the queue updates live without polling
- Add a minimum review time guard — if a moderator approves items in under 2 seconds consistently, flag their account for quality review
- Store ai_flags as a JSONB array so you can index and filter by specific flag categories in future analytics queries
- Use Lovable Plan Mode to design the keyboard shortcut logic and AI integration flow before generating code
AI prompts to try
Copy these prompts to build this project faster.
Design a PostgreSQL schema for a content moderation system with tables for moderation_items, moderation_rules, and moderator_stats. Include RLS policies, indexes for queue ordering by priority and submission time, and a structure for storing AI moderation scores and flag categories.
Build a content moderation tool with a /queue DataTable that supports keyboard shortcuts (A=approve, R=reject, S=skip). Clicking a row opens a ReviewDialog with full content, AI pre-screen score, and approve/reject/escalate buttons. Add a /stats page with moderator performance Cards and trend charts.
Add an AI pre-screening step to the content moderation queue. When a new item is inserted, call the prescreen-content Supabase Edge Function with the item_id and content_text. The function calls the OpenAI Moderation API and updates the row with ai_score and ai_flags. Show these values in the ReviewDialog and the queue DataTable's ai_score Badge column.
Frequently asked questions
How do I prevent moderators from seeing each other's in-progress reviews?
Add a locked_by and locked_at column to moderation_items. When a moderator opens a review Dialog, update locked_by to their user ID and locked_at to now(). Add a filter to the queue query: .or('locked_by.is.null,locked_by.eq.' + userId). Clear the lock if the Dialog is closed without a decision or after 5 minutes.
Is the OpenAI Moderation API actually free?
Yes. The /v1/moderations endpoint is free to use with any OpenAI API key as of March 2026. It does not consume tokens or credits. You only need a standard OpenAI API key stored in Lovable Cloud tab → Secrets as OPENAI_API_KEY.
How do I handle image content in the moderation queue?
Store image URLs in the content_url field. In the ReviewDialog, render an img tag for images. For AI screening of images, the OpenAI Moderation API also accepts image URLs — update the Edge Function to include the image URL in the moderation request alongside or instead of text.
Can I build this on the Lovable free plan?
The queue DataTable, Dialog, and keyboard shortcuts work on the free plan. The AI pre-screening Edge Function requires Lovable Pro. The free plan also limits you to 5 daily credits, which makes iterating on a complex build like this difficult — Pro is strongly recommended.
How do I deploy this and keep it secure?
Click the Publish icon in Lovable and enable access controls to restrict the moderation tool to authenticated users only. In Supabase Auth, configure email-based invitations so only approved moderators can create accounts. Never expose the admin stats page without an auth check.
How do I export moderation audit logs?
Add an Export button to the stats page that calls a Supabase query for all reviewed items in a date range, converts the result to CSV using a simple JavaScript function, and triggers a browser download with URL.createObjectURL on a Blob.
My Lovable build added unwanted changes when I asked for keyboard shortcuts — how do I prevent that?
Use the @filename syntax in your Lovable prompt to scope the change: 'Update @src/hooks/useKeyboardModeration.ts to add the keyboard shortcut handler.' This tells Lovable to only modify that specific file rather than rewriting related components. RapidDev has seen this scoping technique reduce unintended rewrites significantly on complex builds.
How do I add a second-tier review for escalated items?
Add a parent_item_id reference column to moderation_items or use the escalated status as a filter. Create a separate /escalations queue page that only shows escalated items and is accessible only to users with a senior_moderator role stored in a user_roles table in Supabase.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation