# How to Build a Content Moderation Tool with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## 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.

## Before you start

- 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)

## Step-by-step guide

### 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.

```
-- Run in Supabase SQL Editor
create table moderation_items (
  id uuid primary key default gen_random_uuid(),
  content_type text not null check (content_type in ('post','comment','image','video','profile')),
  content_text text,
  content_url text,
  source_user_id text,
  source_platform text,
  ai_score numeric(3,2),
  ai_flags jsonb default '[]',
  status text default 'pending' check (status in ('pending','approved','rejected','escalated','skipped')),
  reviewed_by uuid references auth.users(id),
  reviewed_at timestamptz,
  review_note text,
  priority int default 0,
  submitted_at timestamptz default now()
);

create table moderation_rules (
  id uuid primary key default gen_random_uuid(),
  rule_name text not null,
  rule_type text check (rule_type in ('keyword','threshold','pattern')),
  rule_value text not null,
  action text default 'flag' check (action in ('flag','auto_reject','escalate')),
  is_active boolean default true,
  created_at timestamptz default now()
);

create table moderator_stats (
  id uuid primary key default gen_random_uuid(),
  moderator_id uuid references auth.users(id) unique,
  total_reviewed int default 0,
  total_approved int default 0,
  total_rejected int default 0,
  avg_decision_seconds numeric(6,1),
  updated_at timestamptz default now()
);

create index idx_items_status_submitted on moderation_items(status, submitted_at);
create index idx_items_priority on moderation_items(priority desc, submitted_at asc);

alter table moderation_items enable row level security;
alter table moderation_rules enable row level security;
alter table moderator_stats enable row level security;

create policy "Moderators read queue" on moderation_items for select using (auth.role() = 'authenticated');
create policy "Moderators update queue" on moderation_items for update using (auth.role() = 'authenticated');
create policy "Moderators read rules" on moderation_rules for select using (auth.role() = 'authenticated');
create 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.

```
Build the moderation queue at /queue.

Fetch pending moderation_items ordered by priority DESC, submitted_at ASC (oldest high-priority first).

Render a shadcn DataTable with columns:
- submitted_at (relative time: '2 hours ago')
- content_type Badge (post=blue, comment=gray, image=purple, video=orange, profile=teal)
- content_text (truncated to 80 chars)
- ai_score: colored badge (green < 0.3, yellow 0.3-0.7, red > 0.7) or 'Not scored' if null
- priority Badge (high=red, normal=gray)
- status Badge

Add a selected row state. Clicking a row selects it and opens the ReviewDialog.

Add a keyboard shortcut hook:
- When a row is selected:
  - Press 'A' → approve (update status to 'approved', reviewed_by to current user)
  - Press 'R' → reject (update status to 'rejected')
  - Press 'S' → skip (update status to 'skipped')
  - Press arrow keys → move selection up/down
- Show a keyboard shortcuts legend below the table

Add filter tabs above the table: All | Pending | Escalated | High Priority
Show 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.

```
import { useState } from 'react'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { supabase } from '@/lib/supabase'
import { toast } from 'sonner'

interface ModerationItem {
  id: string
  content_type: string
  content_text: string | null
  content_url: string | null
  source_user_id: string | null
  ai_score: number | null
  ai_flags: string[]
  priority: number
}

interface ReviewDialogProps {
  item: ModerationItem | null
  onDecision: (id: string, status: string) => void
  onClose: () => void
}

export function ReviewDialog({ item, onDecision, onClose }: ReviewDialogProps) {
  const [note, setNote] = useState('')
  const [loading, setLoading] = useState(false)

  const decide = async (status: 'approved' | 'rejected' | 'escalated') => {
    if (!item) return
    setLoading(true)
    const { error } = await supabase.from('moderation_items').update({
      status,
      review_note: note || null,
      reviewed_at: new Date().toISOString()
    }).eq('id', item.id)
    setLoading(false)
    if (error) { toast.error('Failed to save decision'); return }
    toast.success(`Item ${status}`)
    onDecision(item.id, status)
    setNote('')
    onClose()
  }

  const scoreColor = (score: number | null) => {
    if (score === null) return 'bg-gray-100 text-gray-600'
    if (score < 0.3) return 'bg-green-100 text-green-700'
    if (score < 0.7) return 'bg-yellow-100 text-yellow-700'
    return 'bg-red-100 text-red-700'
  }

  return (
    <Dialog open={!!item} onOpenChange={onClose}>
      <DialogContent className="max-w-4xl">
        <div className="grid grid-cols-2 gap-6 h-[500px]">
          <div className="overflow-auto space-y-3">
            <div className="flex gap-2 items-center">
              <Badge variant="outline">{item?.content_type}</Badge>
              {item?.priority === 1 && <Badge variant="destructive">High Priority</Badge>}
            </div>
            {item?.content_text && (
              <div className="bg-muted rounded p-4 text-sm leading-relaxed">{item.content_text}</div>
            )}
            {item?.content_url && (
              <img src={item.content_url} alt="Content" className="rounded max-h-64 object-contain" />
            )}
            <p className="text-xs text-muted-foreground">Source user: {item?.source_user_id ?? 'anonymous'}</p>
          </div>
          <div className="space-y-4">
            <div>
              <p className="text-sm font-medium mb-2">AI Pre-screen Score</p>
              <span className={`px-3 py-1 rounded-full text-sm font-medium ${scoreColor(item?.ai_score ?? null)}`}>
                {item?.ai_score !== null ? (item?.ai_score! * 100).toFixed(0) + '%' : 'Not scored'}
              </span>
            </div>
            {item?.ai_flags && item.ai_flags.length > 0 && (
              <div>
                <p className="text-sm font-medium mb-2">Flagged Categories</p>
                <div className="flex flex-wrap gap-2">
                  {item.ai_flags.map(f => <Badge key={f} variant="destructive">{f}</Badge>)}
                </div>
              </div>
            )}
            <div>
              <p className="text-sm font-medium mb-2">Moderator Note (optional)</p>
              <Textarea value={note} onChange={e => setNote(e.target.value)}
                placeholder="Add context for this decision..." rows={3} />
            </div>
            <div className="flex gap-2 pt-2">
              <Button className="flex-1 bg-green-600 hover:bg-green-700" onClick={() => decide('approved')} disabled={loading}>Approve</Button>
              <Button className="flex-1" variant="destructive" onClick={() => decide('rejected')} disabled={loading}>Reject</Button>
              <Button className="flex-1" variant="outline" onClick={() => decide('escalated')} disabled={loading}>Escalate</Button>
            </div>
          </div>
        </div>
      </DialogContent>
    </Dialog>
  )
}
```

**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.

```
Build a /stats page with two sections:

1. Personal Stats (top)
Fetch from moderator_stats where moderator_id = current user:
- Four Cards: Total Reviewed, Approved Rate, Rejected Rate, Avg Decision Time
- A sparkline chart (BarChart from recharts) showing the last 7 days of decisions

2. Team Trends (bottom)
Fetch last 30 days of moderation_items grouped by date and status.
Render two charts side by side using shadcn Chart (recharts):

a) Line chart: volume per day (x=date, y=count, separate lines for approved/rejected/escalated)
b) Bar chart: breakdown by content_type (x=type, y=count, stacked by status)

Above the charts add a DateRangePicker (two Popover Calendars) to filter the date range.
Fetching data should re-query Supabase when the date range changes.

All charts must be responsive (100% width, use ResponsiveContainer).
Use 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
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type'
}

serve(async (req) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })

  const { item_id, text } = await req.json()
  if (!item_id || !text) {
    return new Response(JSON.stringify({ error: 'item_id and text required' }), { status: 400, headers: corsHeaders })
  }

  const openaiKey = Deno.env.get('OPENAI_API_KEY')
  if (!openaiKey) return new Response(JSON.stringify({ error: 'Missing OPENAI_API_KEY' }), { status: 500, headers: corsHeaders })

  const modRes = await fetch('https://api.openai.com/v1/moderations', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${openaiKey}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ input: text })
  })

  const modData = await modRes.json()
  const result = modData.results?.[0]
  if (!result) return new Response(JSON.stringify({ error: 'No moderation result' }), { status: 500, headers: corsHeaders })

  const flags = Object.entries(result.categories as Record<string, boolean>)
    .filter(([, v]) => v)
    .map(([k]) => k)

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
  )

  await supabase.from('moderation_items').update({
    ai_score: result.category_scores ? Math.max(...Object.values(result.category_scores as Record<string, number>)) : null,
    ai_flags: flags,
    priority: flags.length > 0 ? 1 : 0
  }).eq('id', item_id)

  return new Response(JSON.stringify({ flags, flagged: result.flagged }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } })
})
```

> 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 example

File: `src/hooks/useKeyboardModeration.ts`

```typescript
import { useEffect, useCallback } from 'react'
import { supabase } from '@/lib/supabase'
import { toast } from 'sonner'

type ModerationStatus = 'approved' | 'rejected' | 'skipped' | 'escalated'

interface UseKeyboardModerationOptions {
  selectedItemId: string | null
  onDecision: (id: string, status: ModerationStatus) => void
  onNavigate: (direction: 'up' | 'down') => void
}

export function useKeyboardModeration({ selectedItemId, onDecision, onNavigate }: UseKeyboardModerationOptions) {
  const applyDecision = useCallback(async (status: ModerationStatus) => {
    if (!selectedItemId) return
    const { error } = await supabase.from('moderation_items').update({
      status,
      reviewed_at: new Date().toISOString()
    }).eq('id', selectedItemId)
    if (error) {
      toast.error('Failed to apply decision')
      return
    }
    const labels: Record<ModerationStatus, string> = {
      approved: 'Approved',
      rejected: 'Rejected',
      skipped: 'Skipped',
      escalated: 'Escalated'
    }
    toast.success(labels[status])
    onDecision(selectedItemId, status)
  }, [selectedItemId, onDecision])

  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
      switch (e.key.toLowerCase()) {
        case 'a': applyDecision('approved'); break
        case 'r': applyDecision('rejected'); break
        case 's': applyDecision('skipped'); break
        case 'e': applyDecision('escalated'); break
        case 'arrowdown': e.preventDefault(); onNavigate('down'); break
        case 'arrowup': e.preventDefault(); onNavigate('up'); break
      }
    }
    window.addEventListener('keydown', handler)
    return () => window.removeEventListener('keydown', handler)
  }, [applyDecision, onNavigate])
}
```

## Common mistakes

- **Using the service role key in the frontend moderation UI** — The service role key bypasses RLS entirely. Using it in the browser means any user who inspects network requests can make unrestricted database changes. Fix: Only the Edge Function (prescreen-content) should use SUPABASE_SERVICE_ROLE_KEY. All frontend Supabase calls use the anon key with proper RLS policies.
- **Forgetting to disable keyboard shortcuts when a text field is focused** — Without the target check, pressing R to reject while typing a moderator note will both type 'r' in the field and trigger a rejection simultaneously. Fix: The useKeyboardModeration hook in the complete_code section includes the check: if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return.
- **Fetching chart data in JavaScript after loading all rows** — Loading thousands of moderation_items into JavaScript to group by date will be extremely slow and may crash the browser tab on high-volume queues. Fix: 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.
- **Not setting reviewed_by when a decision is made via keyboard shortcuts** — Without reviewed_by, you lose the audit trail showing which moderator approved or rejected each item — critical for accountability. Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/content-moderation-tool
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/content-moderation-tool
