# How to Build a Content Moderation Tool with V0

- Tool: v0
- Difficulty: Advanced
- Compatibility: V0 Premium recommended, Supabase free tier, OpenAI API account
- Last updated: April 2026

## TL;DR

Build a content moderation tool that automatically scores user-generated content using the OpenAI Moderation API, queues flagged items for human review, and tracks enforcement actions with full audit trails. V0 generates the Next.js review queue and admin interface while Supabase database webhooks trigger the auto-moderation pipeline on new content.

## Before you start

- A V0 account (Premium recommended for multi-file generation)
- A Supabase project with database webhooks enabled
- An OpenAI API key for the Moderation API
- Basic familiarity with Next.js App Router and TypeScript

## Step-by-step guide

### 1. Create the Supabase Schema with Strike Trigger

*The three-strike trigger automates user bans at the database level, ensuring consistent enforcement regardless of which moderator takes the action or whether it is done through the UI or API.*

Set up tables for content items, moderation actions, moderation rules, and user strikes. Create a trigger on moderation_actions that increments strike_count when a warning is issued and automatically sets is_banned when strikes reach three.

```
-- Content items to moderate
create table content_items (
  id uuid primary key default gen_random_uuid(),
  author_id uuid references auth.users not null,
  content_type text check (content_type in ('post','comment','image','profile')) not null,
  content_text text,
  content_url text,
  status text check (status in ('pending','approved','rejected','escalated')) default 'pending',
  auto_score numeric,
  auto_categories text[] default '{}',
  source_table text,
  source_id uuid,
  created_at timestamptz default now()
);

-- Moderation actions (audit trail)
create table moderation_actions (
  id uuid primary key default gen_random_uuid(),
  content_id uuid references content_items not null,
  moderator_id uuid references auth.users not null,
  action text check (action in ('approve','reject','escalate','warn','ban')) not null,
  reason text,
  created_at timestamptz default now()
);

-- Custom moderation rules
create table moderation_rules (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  pattern text not null,
  action text check (action in ('flag','auto_reject')) not null,
  is_active boolean default true,
  created_at timestamptz default now()
);

-- User strike tracking
create table user_strikes (
  user_id uuid primary key references auth.users,
  strike_count int default 0,
  is_banned boolean default false,
  last_strike_at timestamptz
);

-- Trigger: increment strikes on warn/ban action
create or replace function handle_moderation_action()
returns trigger as $$
begin
  if NEW.action in ('warn', 'ban') then
    -- Get the content author
    declare
      v_author_id uuid;
    begin
      select author_id into v_author_id
      from content_items where id = NEW.content_id;

      -- Upsert user strikes
      insert into user_strikes (user_id, strike_count, last_strike_at)
      values (v_author_id, 1, now())
      on conflict (user_id) do update set
        strike_count = user_strikes.strike_count + 1,
        last_strike_at = now();

      -- Auto-ban at 3 strikes
      update user_strikes
      set is_banned = true
      where user_id = v_author_id and strike_count >= 3;
    end;
  end if;
  return NEW;
end;
$$ language plpgsql security definer;

create trigger on_moderation_action
  after insert on moderation_actions
  for each row execute function handle_moderation_action();

-- RLS
alter table content_items enable row level security;
alter table moderation_actions enable row level security;
alter table moderation_rules enable row level security;
alter table user_strikes enable row level security;

-- Only moderators can access (use a role check in practice)
create policy "Moderators read content" on content_items for select using (true);
create policy "Moderators update content" on content_items for update using (true);
create policy "Moderators read actions" on moderation_actions for select using (true);
create policy "Moderators create actions" on moderation_actions for insert with check (true);
create policy "Moderators manage rules" on moderation_rules for all using (true);
create policy "Moderators read strikes" on user_strikes for select using (true);
```

**Expected result:** Database tables created with a trigger that automatically tracks strikes and bans users after three warnings.

### 2. Build the Auto-Moderation API Pipeline

*Running the OpenAI Moderation API and custom regex rules in parallel speeds up the scoring pipeline. Storing the OPENAI_API_KEY without NEXT_PUBLIC_ prefix in V0's Vars tab ensures it is never exposed to the browser.*

Create an API route that receives content items, runs OpenAI Moderation API and custom regex rules concurrently, computes a combined score, and either auto-approves clean content or queues it for human review.

```
// app/api/moderate/auto/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

const AUTO_APPROVE_THRESHOLD = 0.3;
const WEBHOOK_SECRET = process.env.MODERATION_WEBHOOK_SECRET!;

export async function POST(request: NextRequest) {
  // Validate webhook secret
  const authHeader = request.headers.get('authorization');
  if (authHeader !== `Bearer ${WEBHOOK_SECRET}`) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { record } = await request.json();
  const contentId = record.id;
  const contentText = record.content_text || '';

  // Run OpenAI Moderation and custom rules in parallel
  const [aiResult, ruleResult] = await Promise.all([
    moderateWithOpenAI(contentText),
    moderateWithRules(contentText),
  ]);

  // Combine scores (max of AI score and rule severity)
  const combinedScore = Math.max(aiResult.score, ruleResult.score);
  const categories = [...aiResult.categories, ...ruleResult.matchedRules];

  // Update content item with scores
  await supabase.from('content_items').update({
    auto_score: combinedScore,
    auto_categories: categories,
    status: combinedScore < AUTO_APPROVE_THRESHOLD ? 'approved' : 'pending',
  }).eq('id', contentId);

  return NextResponse.json({
    contentId,
    score: combinedScore,
    status: combinedScore < AUTO_APPROVE_THRESHOLD ? 'auto_approved' : 'queued_for_review',
  });
}

async function moderateWithOpenAI(text: string) {
  if (!text.trim()) return { score: 0, categories: [] };

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

  const data = await response.json();
  const result = data.results[0];

  // Get the highest category score
  const scores = Object.values(result.category_scores) as number[];
  const maxScore = Math.max(...scores);

  // Get flagged categories
  const flaggedCategories = Object.entries(result.categories)
    .filter(([, flagged]) => flagged)
    .map(([category]) => category);

  return { score: maxScore, categories: flaggedCategories };
}

async function moderateWithRules(text: string) {
  const { data: rules } = await supabase
    .from('moderation_rules')
    .select('*')
    .eq('is_active', true);

  const matchedRules: string[] = [];
  let maxScore = 0;

  for (const rule of rules ?? []) {
    try {
      const regex = new RegExp(rule.pattern, 'gi');
      if (regex.test(text)) {
        matchedRules.push(rule.name);
        maxScore = Math.max(maxScore, rule.action === 'auto_reject' ? 1.0 : 0.7);
      }
    } catch {
      // Skip invalid regex patterns
    }
  }

  return { score: maxScore, matchedRules };
}
```

**Expected result:** The API route scores content using OpenAI and custom rules in parallel, auto-approving clean content and queuing flagged content for human review.

### 3. Build the Moderation Review Queue

*Moderators need a focused interface to quickly review flagged content, see the severity score, and take enforcement actions. Color-coded badges help prioritize the most severe items.*

Create the review queue page with filters for status, content type, and severity. Each item shows a preview, auto-score with color coding, and quick action buttons for approve, reject, escalate, and warn.

```
// app/moderation/page.tsx
import { createClient } from '@/lib/supabase/server';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ModerationActions } from './moderation-actions';
import { Shield, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';

function getSeverityColor(score: number): string {
  if (score >= 0.8) return 'bg-red-100 text-red-800';
  if (score >= 0.5) return 'bg-orange-100 text-orange-800';
  if (score >= 0.3) return 'bg-yellow-100 text-yellow-800';
  return 'bg-green-100 text-green-800';
}

export default async function ModerationPage() {
  const supabase = await createClient();

  const { data: items } = await supabase
    .from('content_items')
    .select('*')
    .in('status', ['pending', 'escalated'])
    .order('auto_score', { ascending: false })
    .order('created_at', { ascending: true });

  const { data: stats } = await supabase
    .from('content_items')
    .select('status')
    .in('status', ['pending', 'escalated']);

  const pending = stats?.filter(s => s.status === 'pending').length ?? 0;
  const escalated = stats?.filter(s => s.status === 'escalated').length ?? 0;
  const allItems = items ?? [];

  return (
    <div className="max-w-5xl mx-auto p-6 space-y-6">
      <div className="flex items-center justify-between">
        <div className="flex items-center gap-3">
          <Shield className="h-8 w-8 text-primary" />
          <div>
            <h1 className="text-2xl font-bold">Content Moderation</h1>
            <p className="text-sm text-muted-foreground">
              {pending} pending · {escalated} escalated
            </p>
          </div>
        </div>
      </div>

      <Tabs defaultValue="all">
        <TabsList>
          <TabsTrigger value="all">All ({allItems.length})</TabsTrigger>
          <TabsTrigger value="pending">Pending ({pending})</TabsTrigger>
          <TabsTrigger value="escalated">Escalated ({escalated})</TabsTrigger>
        </TabsList>

        {['all', 'pending', 'escalated'].map((tab) => (
          <TabsContent key={tab} value={tab} className="space-y-3">
            {allItems
              .filter(item => tab === 'all' || item.status === tab)
              .map((item) => (
                <Card key={item.id}>
                  <CardContent className="pt-4">
                    <div className="flex items-start gap-4">
                      <div className="flex-1 min-w-0">
                        <div className="flex items-center gap-2 mb-2">
                          <Badge variant="outline">{item.content_type}</Badge>
                          <Badge className={getSeverityColor(item.auto_score ?? 0)}>
                            Score: {(item.auto_score ?? 0).toFixed(2)}
                          </Badge>
                          {item.auto_categories?.map((cat: string) => (
                            <Badge key={cat} variant="secondary" className="text-xs">
                              {cat}
                            </Badge>
                          ))}
                        </div>
                        <p className="text-sm whitespace-pre-wrap line-clamp-3">
                          {item.content_text}
                        </p>
                        {item.content_url && (
                          <p className="text-xs text-muted-foreground mt-1">
                            Media: {item.content_url}
                          </p>
                        )}
                        <p className="text-xs text-muted-foreground mt-2">
                          Submitted {new Date(item.created_at).toLocaleString()}
                        </p>
                      </div>
                      <ModerationActions contentId={item.id} />
                    </div>
                  </CardContent>
                </Card>
              ))}
          </TabsContent>
        ))}
      </Tabs>
    </div>
  );
}

// app/moderation/moderation-actions.tsx
'use client';

import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { CheckCircle, XCircle, AlertTriangle, Ban } from 'lucide-react';
import { moderateContent } from '@/app/actions/moderate';

export function ModerationActions({ contentId }: { contentId: string }) {
  const [reason, setReason] = useState('');
  const [loading, setLoading] = useState(false);

  async function handleAction(action: string) {
    setLoading(true);
    await moderateContent(contentId, action, reason);
    setReason('');
    setLoading(false);
  }

  return (
    <div className="flex flex-col gap-1">
      <Button size="sm" variant="outline" className="text-green-600"
        onClick={() => handleAction('approve')} disabled={loading}>
        <CheckCircle className="h-3 w-3 mr-1" /> Approve
      </Button>
      <Button size="sm" variant="outline" className="text-red-600"
        onClick={() => handleAction('reject')} disabled={loading}>
        <XCircle className="h-3 w-3 mr-1" /> Reject
      </Button>
      <Button size="sm" variant="outline" className="text-orange-600"
        onClick={() => handleAction('escalate')} disabled={loading}>
        <AlertTriangle className="h-3 w-3 mr-1" /> Escalate
      </Button>
      <Dialog>
        <DialogTrigger asChild>
          <Button size="sm" variant="outline" className="text-red-800">
            <Ban className="h-3 w-3 mr-1" /> Warn
          </Button>
        </DialogTrigger>
        <DialogContent>
          <DialogHeader><DialogTitle>Issue Warning</DialogTitle></DialogHeader>
          <Textarea placeholder="Reason for warning..." value={reason}
            onChange={e => setReason(e.target.value)} />
          <Button onClick={() => handleAction('warn')} disabled={loading}>
            {loading ? 'Sending...' : 'Send Warning'}
          </Button>
        </DialogContent>
      </Dialog>
    </div>
  );
}
```

**Expected result:** A moderation review queue sorted by severity with color-coded scores, content previews, and quick action buttons. Warning actions open a dialog for providing a reason.

### 4. Create the Server Action for Enforcement and Audit Trail

*Server Actions handle enforcement securely on the server while creating an audit trail entry for every moderation decision. This provides accountability and allows reviewing past decisions.*

Build the moderateContent Server Action that updates the content item status, inserts a moderation action record, and triggers the strike system through the database trigger.

```
// app/actions/moderate.ts
'use server';

import { createClient } from '@/lib/supabase/server';
import { revalidatePath } from 'next/cache';

export async function moderateContent(
  contentId: string,
  action: string,
  reason?: string
) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) throw new Error('Not authenticated');

  // Map action to content status
  const statusMap: Record<string, string> = {
    approve: 'approved',
    reject: 'rejected',
    escalate: 'escalated',
    warn: 'rejected',
    ban: 'rejected',
  };

  // Update content item status
  await supabase
    .from('content_items')
    .update({ status: statusMap[action] })
    .eq('id', contentId);

  // Create audit trail entry
  // This INSERT triggers the handle_moderation_action function
  // which automatically handles strikes and bans
  await supabase.from('moderation_actions').insert({
    content_id: contentId,
    moderator_id: user.id,
    action,
    reason: reason || null,
  });

  revalidatePath('/moderation');
  return { success: true };
}

// app/moderation/history/page.tsx
import { createClient } from '@/lib/supabase/server';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';

const actionColors: Record<string, string> = {
  approve: 'bg-green-100 text-green-800',
  reject: 'bg-red-100 text-red-800',
  escalate: 'bg-orange-100 text-orange-800',
  warn: 'bg-yellow-100 text-yellow-800',
  ban: 'bg-red-200 text-red-900',
};

export default async function HistoryPage() {
  const supabase = await createClient();

  const { data: actions } = await supabase
    .from('moderation_actions')
    .select('*, content_items(content_type, content_text)')
    .order('created_at', { ascending: false })
    .limit(100);

  return (
    <div className="max-w-5xl mx-auto p-6">
      <h1 className="text-2xl font-bold mb-6">Moderation History</h1>
      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>Date</TableHead>
            <TableHead>Content Type</TableHead>
            <TableHead>Action</TableHead>
            <TableHead>Reason</TableHead>
            <TableHead>Content Preview</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {actions?.map((action) => (
            <TableRow key={action.id}>
              <TableCell className="text-sm">
                {new Date(action.created_at).toLocaleString()}
              </TableCell>
              <TableCell>
                <Badge variant="outline">{action.content_items?.content_type}</Badge>
              </TableCell>
              <TableCell>
                <Badge className={actionColors[action.action]}>{action.action}</Badge>
              </TableCell>
              <TableCell className="text-sm text-muted-foreground max-w-[200px] truncate">
                {action.reason || '—'}
              </TableCell>
              <TableCell className="text-sm max-w-[300px] truncate">
                {action.content_items?.content_text}
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
}
```

**Expected result:** Every moderation action creates an audit trail entry. Warning and ban actions automatically trigger the strike system via database triggers. A history page shows all past moderation decisions.

### 5. Configure Database Webhooks and Environment Variables

*Supabase database webhooks automatically trigger the auto-moderation pipeline when new content is inserted, creating a fully automated content screening system without polling.*

Set up a Supabase database webhook that fires on INSERT to the content_items table and calls the auto-moderation API route. Store all API keys in V0's Vars tab without the NEXT_PUBLIC_ prefix.

```
// Supabase Database Webhook configuration:
// 1. Go to Supabase Dashboard > Database > Webhooks
// 2. Create new webhook:
//    - Name: auto-moderate-content
//    - Table: content_items
//    - Events: INSERT
//    - Type: HTTP Request
//    - Method: POST
//    - URL: https://your-app.vercel.app/api/moderate/auto
//    - Headers: Authorization: Bearer YOUR_WEBHOOK_SECRET

// Environment variables for V0's Vars tab:
// OPENAI_API_KEY=sk-...
// MODERATION_WEBHOOK_SECRET=your-random-secret-string
// NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
// NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
// SUPABASE_SERVICE_ROLE_KEY=eyJ...

// app/moderation/rules/page.tsx
'use client';

import { useState, useEffect } from 'react';
import { createClient } from '@/lib/supabase/client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Plus, Trash2 } from 'lucide-react';

type Rule = {
  id: string;
  name: string;
  pattern: string;
  action: string;
  is_active: boolean;
};

export default function RulesPage() {
  const supabase = createClient();
  const [rules, setRules] = useState<Rule[]>([]);
  const [newName, setNewName] = useState('');
  const [newPattern, setNewPattern] = useState('');
  const [newAction, setNewAction] = useState('flag');

  useEffect(() => {
    supabase.from('moderation_rules').select('*').order('created_at').then(({ data }) => {
      if (data) setRules(data);
    });
  }, []);

  async function addRule() {
    if (!newName || !newPattern) return;
    // Validate regex
    try { new RegExp(newPattern); } catch { return; }

    const { data } = await supabase.from('moderation_rules').insert({
      name: newName, pattern: newPattern, action: newAction,
    }).select().single();

    if (data) {
      setRules([...rules, data]);
      setNewName(''); setNewPattern('');
    }
  }

  async function toggleRule(id: string, is_active: boolean) {
    await supabase.from('moderation_rules').update({ is_active }).eq('id', id);
    setRules(rules.map(r => r.id === id ? { ...r, is_active } : r));
  }

  async function deleteRule(id: string) {
    await supabase.from('moderation_rules').delete().eq('id', id);
    setRules(rules.filter(r => r.id !== id));
  }

  return (
    <div className="max-w-4xl mx-auto p-6 space-y-6">
      <h1 className="text-2xl font-bold">Moderation Rules</h1>

      <Card>
        <CardHeader><CardTitle className="text-lg">Add Rule</CardTitle></CardHeader>
        <CardContent className="flex gap-2">
          <Input placeholder="Rule name" value={newName} onChange={e => setNewName(e.target.value)} />
          <Input placeholder="Regex pattern" value={newPattern} onChange={e => setNewPattern(e.target.value)} className="font-mono" />
          <Select value={newAction} onValueChange={setNewAction}>
            <SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
            <SelectContent>
              <SelectItem value="flag">Flag</SelectItem>
              <SelectItem value="auto_reject">Auto-reject</SelectItem>
            </SelectContent>
          </Select>
          <Button onClick={addRule}><Plus className="h-4 w-4" /></Button>
        </CardContent>
      </Card>

      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>Active</TableHead>
            <TableHead>Name</TableHead>
            <TableHead>Pattern</TableHead>
            <TableHead>Action</TableHead>
            <TableHead></TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {rules.map(rule => (
            <TableRow key={rule.id}>
              <TableCell>
                <Switch checked={rule.is_active} onCheckedChange={v => toggleRule(rule.id, v)} />
              </TableCell>
              <TableCell className="font-medium">{rule.name}</TableCell>
              <TableCell className="font-mono text-sm">{rule.pattern}</TableCell>
              <TableCell>{rule.action}</TableCell>
              <TableCell>
                <Button variant="ghost" size="icon" onClick={() => deleteRule(rule.id)}>
                  <Trash2 className="h-4 w-4" />
                </Button>
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
}
```

**Expected result:** Database webhooks automatically trigger auto-moderation when new content is inserted. A rules management page lets moderators create, toggle, and delete custom regex rules.

## Complete code example

File: `app/api/moderate/auto/route.ts`

```typescript
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

const AUTO_APPROVE_THRESHOLD = 0.3;
const WEBHOOK_SECRET = process.env.MODERATION_WEBHOOK_SECRET!;

export async function POST(request: NextRequest) {
  const authHeader = request.headers.get('authorization');
  if (authHeader !== `Bearer ${WEBHOOK_SECRET}`) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { record } = await request.json();
  const contentId = record.id;
  const contentText = record.content_text || '';

  const [aiResult, ruleResult] = await Promise.all([
    moderateWithOpenAI(contentText),
    moderateWithRules(contentText),
  ]);

  const combinedScore = Math.max(aiResult.score, ruleResult.score);
  const categories = [...aiResult.categories, ...ruleResult.matchedRules];

  await supabase.from('content_items').update({
    auto_score: combinedScore,
    auto_categories: categories,
    status: combinedScore < AUTO_APPROVE_THRESHOLD ? 'approved' : 'pending',
  }).eq('id', contentId);

  return NextResponse.json({ contentId, score: combinedScore, status: combinedScore < AUTO_APPROVE_THRESHOLD ? 'auto_approved' : 'queued' });
}

async function moderateWithOpenAI(text: string) {
  if (!text.trim()) return { score: 0, categories: [] as string[] };
  const response = await fetch('https://api.openai.com/v1/moderations', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
    body: JSON.stringify({ input: text }),
  });
  const data = await response.json();
  const result = data.results[0];
  const scores = Object.values(result.category_scores) as number[];
  const maxScore = Math.max(...scores);
  const flaggedCategories = Object.entries(result.categories).filter(([, f]) => f).map(([c]) => c);
  return { score: maxScore, categories: flaggedCategories };
}

async function moderateWithRules(text: string) {
  const { data: rules } = await supabase.from('moderation_rules').select('*').eq('is_active', true);
  const matchedRules: string[] = [];
  let maxScore = 0;
  for (const rule of rules ?? []) {
    try {
      if (new RegExp(rule.pattern, 'gi').test(text)) {
        matchedRules.push(rule.name);
        maxScore = Math.max(maxScore, rule.action === 'auto_reject' ? 1.0 : 0.7);
      }
    } catch { /* skip invalid regex */ }
  }
  return { score: maxScore, matchedRules };
}
```

## Common mistakes

- **undefined** — undefined Fix: undefined
- **undefined** — undefined Fix: undefined
- **undefined** — undefined Fix: undefined
- **undefined** — undefined Fix: undefined

## Best practices

- Store OPENAI_API_KEY and MODERATION_WEBHOOK_SECRET in V0's Vars tab without the NEXT_PUBLIC_ prefix
- Run OpenAI Moderation API and custom regex rules in parallel with Promise.all to minimize latency
- Auto-approve content below the threshold to reduce moderator workload while flagging borderline content
- Use database triggers for the strike system so it works regardless of how actions are created (UI, API, or bulk operations)
- Validate the webhook secret on every incoming request to prevent unauthorized moderation scoring
- Create an audit trail entry for every moderation decision to maintain accountability
- Use Supabase database webhooks instead of polling to trigger auto-moderation in real time

## Frequently asked questions

### How does the auto-moderation pipeline work?

When new content is inserted into the content_items table, a Supabase database webhook sends the record to the /api/moderate/auto endpoint. That route runs the OpenAI Moderation API and custom regex rules in parallel using Promise.all. It combines the highest score from both sources. If the score is below 0.3, the content is auto-approved. Otherwise it stays in 'pending' status for human review.

### How does the three-strike system work?

A database trigger fires on every INSERT into moderation_actions. If the action is 'warn' or 'ban', it looks up the content author and upserts their user_strikes record, incrementing strike_count. When strike_count reaches 3, the trigger automatically sets is_banned to true. This works regardless of whether the action was taken through the UI, API, or bulk operations.

### Where should I store the OpenAI API key?

Store OPENAI_API_KEY in V0's Vars tab without the NEXT_PUBLIC_ prefix. The auto-moderation API route accesses it via process.env.OPENAI_API_KEY server-side. Never expose this key to the browser — it controls your OpenAI billing and usage quota.

### What is the OpenAI Moderation API?

The OpenAI Moderation API is a free endpoint that classifies text into categories like hate, violence, self-harm, sexual content, and harassment. It returns a score from 0 to 1 for each category and a boolean flag for whether the content violates that category. It is free to use and does not count against your GPT usage quota.

### How do I set up the Supabase database webhook?

Go to Supabase Dashboard, navigate to Database then Webhooks, and create a new webhook. Set the table to content_items, event to INSERT, method to POST, and URL to your production endpoint (https://your-app.vercel.app/api/moderate/auto). Add an Authorization header with Bearer followed by your webhook secret. The webhook fires automatically on every new content insert.

### Can I moderate images in addition to text?

Yes. The content_items table has a content_url column for image URLs. You can extend the auto-moderation route to send image URLs to OpenAI's moderation or vision API for visual content screening. Process text and image moderation in parallel for faster throughput.

### Can RapidDev help build a custom moderation system?

Yes. RapidDev has helped over 600 platforms implement content moderation with AI scoring, custom rule engines, and compliance reporting. Book a free consultation at RapidDev to discuss your specific moderation requirements.

---

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