Skip to main content
RapidDev - Software Development Agency

How to Build a Content Moderation Tool with Lovable

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'll build

  • Moderation queue DataTable with keyboard shortcuts for rapid approve/reject
  • Full-content review Dialog showing text, media, and metadata side by side
  • Moderation rules editor for configuring auto-flag keywords and thresholds
  • Moderator stats dashboard with personal metrics and team leaderboard
  • Trend charts showing moderation volume and decision rates over time
  • AI pre-screening Edge Function that scores content before queue insertion
  • Audit log of all moderation decisions with reviewer and timestamp
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate14 min read2-3 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

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

LovableUI scaffolding and Supabase connection
React + TypeScriptQueue components, keyboard shortcut hooks, and chart integration
Supabase PostgreSQLmoderation_items, moderation_rules, moderator_stats tables
Supabase Edge FunctionsAI pre-screening via external moderation API
shadcn/uiDataTable, Dialog, Badge, Chart, Card, Tabs, Alert
Tailwind CSSQueue layout, status colors, and compact row styling

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

1

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.

supabase_schema.sql
1-- Run in Supabase SQL Editor
2create 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);
18
19create 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);
28
29create 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);
38
39create index idx_items_status_submitted on moderation_items(status, submitted_at);
40create index idx_items_priority on moderation_items(priority desc, submitted_at asc);
41
42alter table moderation_items enable row level security;
43alter table moderation_rules enable row level security;
44alter table moderator_stats enable row level security;
45
46create 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.

2

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.

prompt.txt
1Build the moderation queue at /queue.
2
3Fetch pending moderation_items ordered by priority DESC, submitted_at ASC (oldest high-priority first).
4
5Render 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 null
10- priority Badge (high=red, normal=gray)
11- status Badge
12
13Add a selected row state. Clicking a row selects it and opens the ReviewDialog.
14
15Add 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/down
21- Show a keyboard shortcuts legend below the table
22
23Add filter tabs above the table: All | Pending | Escalated | High Priority
24Show 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.

3

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.

src/components/ReviewDialog.tsx
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'
9
10interface ModerationItem {
11 id: string
12 content_type: string
13 content_text: string | null
14 content_url: string | null
15 source_user_id: string | null
16 ai_score: number | null
17 ai_flags: string[]
18 priority: number
19}
20
21interface ReviewDialogProps {
22 item: ModerationItem | null
23 onDecision: (id: string, status: string) => void
24 onClose: () => void
25}
26
27export function ReviewDialog({ item, onDecision, onClose }: ReviewDialogProps) {
28 const [note, setNote] = useState('')
29 const [loading, setLoading] = useState(false)
30
31 const decide = async (status: 'approved' | 'rejected' | 'escalated') => {
32 if (!item) return
33 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 }
46
47 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 }
53
54 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.

4

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.

prompt.txt
1Build a /stats page with two sections:
2
31. Personal Stats (top)
4Fetch from moderator_stats where moderator_id = current user:
5- Four Cards: Total Reviewed, Approved Rate, Rejected Rate, Avg Decision Time
6- A sparkline chart (BarChart from recharts) showing the last 7 days of decisions
7
82. 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):
11
12a) 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)
14
15Above the charts add a DateRangePicker (two Popover Calendars) to filter the date range.
16Fetching data should re-query Supabase when the date range changes.
17
18All 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.

5

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.

supabase/functions/prescreen-content/index.ts
1// supabase/functions/prescreen-content/index.ts
2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
4
5const corsHeaders = {
6 'Access-Control-Allow-Origin': '*',
7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type'
8}
9
10serve(async (req) => {
11 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
12
13 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 }
17
18 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 })
20
21 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 })
26
27 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 })
30
31 const flags = Object.entries(result.categories as Record<string, boolean>)
32 .filter(([, v]) => v)
33 .map(([k]) => k)
34
35 const supabase = createClient(
36 Deno.env.get('SUPABASE_URL')!,
37 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
38 )
39
40 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 : 0
44 }).eq('id', item_id)
45
46 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

src/hooks/useKeyboardModeration.ts
1import { useEffect, useCallback } from 'react'
2import { supabase } from '@/lib/supabase'
3import { toast } from 'sonner'
4
5type ModerationStatus = 'approved' | 'rejected' | 'skipped' | 'escalated'
6
7interface UseKeyboardModerationOptions {
8 selectedItemId: string | null
9 onDecision: (id: string, status: ModerationStatus) => void
10 onNavigate: (direction: 'up' | 'down') => void
11}
12
13export function useKeyboardModeration({ selectedItemId, onDecision, onNavigate }: UseKeyboardModerationOptions) {
14 const applyDecision = useCallback(async (status: ModerationStatus) => {
15 if (!selectedItemId) return
16 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 return
23 }
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])
33
34 useEffect(() => {
35 const handler = (e: KeyboardEvent) => {
36 if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
37 switch (e.key.toLowerCase()) {
38 case 'a': applyDecision('approved'); break
39 case 'r': applyDecision('rejected'); break
40 case 's': applyDecision('skipped'); break
41 case 'e': applyDecision('escalated'); break
42 case 'arrowdown': e.preventDefault(); onNavigate('down'); break
43 case 'arrowup': e.preventDefault(); onNavigate('up'); break
44 }
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.

ChatGPT Prompt

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.

Lovable Prompt

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.

Build Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.