Skip to content

Supabase + Vettly

Moderate user-generated content in your Supabase app.

Overview

This guide covers:

  • Moderating content before inserting into Supabase
  • Using Edge Functions for server-side moderation
  • Database triggers for async moderation

Option 1: Client-Side with RLS

Moderate in your client before inserting:

ts
// lib/supabase.ts
import { createClient } from '@supabase/supabase-js'
import { ModerationClient } from '@nextauralabs/vettly-sdk'

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

const vettly = new ModerationClient({
  apiKey: process.env.NEXT_PUBLIC_VETTLY_API_KEY!
})

export async function createPost(content: string, userId: string) {
  // 1. Moderate first
  const modResult = await vettly.check({
    content,
    contentType: 'text'
  })

  if (modResult.action === 'block') {
    throw new Error('Content not allowed')
  }

  // 2. Insert if allowed
  const { data, error } = await supabase
    .from('posts')
    .insert({
      content,
      user_id: userId,
      moderation_status: modResult.action,
      moderation_id: modResult.decisionId
    })
    .select()
    .single()

  return data
}

More secure - keeps API key server-side.

Create the Edge Function

bash
supabase functions new moderate-content
ts
// supabase/functions/moderate-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 })
  }

  try {
    const { content, contentType = 'text' } = await req.json()

    // Call Vettly API
    const modResponse = await fetch('https://api.vettly.dev/v1/check', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${Deno.env.get('VETTLY_API_KEY')}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ content, contentType })
    })

    const result = await modResponse.json()

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

  } catch (error) {
    return new Response(JSON.stringify({ error: error.message }), {
      status: 500,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' }
    })
  }
})

Set the Secret

bash
supabase secrets set VETTLY_API_KEY=vettly_your_api_key

Deploy

bash
supabase functions deploy moderate-content

Use in Your App

ts
// Client-side
const { data, error } = await supabase.functions.invoke('moderate-content', {
  body: { content: userMessage, contentType: 'text' }
})

if (data.action === 'block') {
  alert('Content not allowed')
  return
}

// Proceed with insert
await supabase.from('posts').insert({ content: userMessage })

Option 3: Database Webhook

Moderate asynchronously after insert.

1. Create a status column

sql
alter table posts add column moderation_status text default 'pending';
alter table posts add column moderation_id text;

2. Create webhook endpoint

ts
// pages/api/webhooks/supabase-moderate.ts (Next.js example)
import { ModerationClient } from '@nextauralabs/vettly-sdk'
import { createClient } from '@supabase/supabase-js'

const vettly = new ModerationClient({
  apiKey: process.env.VETTLY_API_KEY!
})

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_KEY! // Service key for admin access
)

export default async function handler(req, res) {
  const { record } = req.body

  // Moderate the content
  const result = await vettly.check({
    content: record.content,
    contentType: 'text'
  })

  // Update the record
  await supabase
    .from('posts')
    .update({
      moderation_status: result.action,
      moderation_id: result.decisionId
    })
    .eq('id', record.id)

  // If blocked, optionally delete or hide
  if (result.action === 'block') {
    await supabase
      .from('posts')
      .update({ visible: false })
      .eq('id', record.id)
  }

  res.status(200).json({ success: true })
}

3. Configure Supabase webhook

In Supabase Dashboard → Database → Webhooks:

  • Table: posts
  • Events: INSERT
  • URL: https://yourapp.com/api/webhooks/supabase-moderate

Filtering Moderated Content

Only show approved content:

ts
// Show only allowed content
const { data: posts } = await supabase
  .from('posts')
  .select('*')
  .eq('moderation_status', 'allow')
  .order('created_at', { ascending: false })

Or use Row Level Security:

sql
-- RLS policy to hide blocked content
create policy "Hide blocked content"
  on posts for select
  using (moderation_status != 'block');

Image Moderation with Supabase Storage

ts
// 1. Upload to temp bucket
const { data: upload } = await supabase.storage
  .from('temp-uploads')
  .upload(`${userId}/${filename}`, file)

// 2. Get signed URL
const { data: urlData } = await supabase.storage
  .from('temp-uploads')
  .createSignedUrl(upload.path, 60)

// 3. Download and moderate
const response = await fetch(urlData.signedUrl)
const buffer = await response.arrayBuffer()
const base64 = Buffer.from(buffer).toString('base64')

const modResult = await vettly.check({
  content: base64,
  contentType: 'image'
})

// 4. Move to permanent storage if allowed
if (modResult.action !== 'block') {
  await supabase.storage
    .from('uploads')
    .copy(`temp-uploads/${upload.path}`, `uploads/${upload.path}`)
}

// 5. Clean up temp
await supabase.storage
  .from('temp-uploads')
  .remove([upload.path])

Real-time Moderation Status

Subscribe to moderation updates:

ts
// Subscribe to changes
supabase
  .channel('post-moderation')
  .on(
    'postgres_changes',
    {
      event: 'UPDATE',
      schema: 'public',
      table: 'posts',
      filter: `user_id=eq.${userId}`
    },
    (payload) => {
      if (payload.new.moderation_status === 'block') {
        showNotification('Your post was removed for policy violation')
      }
    }
  )
  .subscribe()

Next Steps