React components for content moderation. ModeratedTextarea, ImageUpload, and VideoUpload with App Store compliance.
npm install @vettly/reactReact components for App Store Guideline 1.2 compliance. Real-time policy feedback, decision tracking, and moderated uploads.
Apple requires every iOS app with user-generated content to implement four things. This package handles the client-side UX for content filtering and audit trails — pair with @vettly/sdk on the server for reporting and blocking:
| Requirement | Guideline | React Integration |
|-------------|-----------|-------------------|
| Content filtering | 1.2.1 | , , |
| User reporting | 1.2.2 | Pair with server-side SDK (POST /v1/reports) |
| User blocking | 1.2.3 | Pair with server-side SDK (POST /v1/blocks) |
| Audit trail | — | Every check returns decisionId — store it with your content |
``typescript
import { useModeration } from '@vettly/react'
function CommentInput() {
const { result, check } = useModeration({
apiKey: 'pk_live_...',
policyId: 'app-store',
})
return (
style={{
borderColor: result.action === 'block' ? 'red' : 'green'
}}
/>
)
}
`
> Note: Client-side moderation improves UX but is not a security boundary. Always validate on the server before persisting content.
Immediate feedback - Users see policy violations as they type, not after submission.
Same policies - The React SDK uses your Vettly policies, ensuring consistent enforcement between client preview and server validation.
Decision tracking - Every client-side check returns a decisionId for your audit trail, just like server-side calls.
Always validate server-side - Client-side moderation improves UX but is not a security boundary. Always validate on the server before persisting content.
`bash`
npm install @vettly/react
`tsx
import { ModeratedTextarea } from '@vettly/react'
import '@vettly/react/styles.css'
function CommentForm() {
const handleSubmit = async (content: string, decisionId: string) => {
// Submit to your API with the decision ID for audit trail
await api.createComment({
content,
moderationDecisionId: decisionId
})
}
return (
policyId="community-safe"
placeholder="Write a comment..."
onModerationResult={(result) => {
console.log(Decision: ${result.action} (${result.decisionId}))`
}}
/>
)
}
---
A textarea with real-time content moderation and visual feedback.
`tsx
apiKey="pk_live_..."
policyId="community-safe"
// Content
value={content}
onChange={(value, result) => {
setContent(value)
console.log(Action: ${result.action})
}}
placeholder="Type something..."
// Behavior
debounceMs={500} // Delay before checking (default: 500ms)
blockUnsafe={false} // Prevent typing when content is unsafe
useFast={true} // Use fast endpoint (<100ms responses)
disabled={false}
// Feedback
showFeedback={true} // Show built-in feedback UI
customFeedback={(result) => (
isChecking={result.isChecking}
error={result.error}
/>
)}
// Callbacks
onModerationResult={(result) => {
// Full CheckResponse with decisionId
console.log(result.decisionId)
}}
onModerationError={(error) => {
console.error('Moderation failed:', error)
}}
// Standard textarea props
className="my-textarea"
rows={4}
maxLength={1000}
/>
`
#### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| apiKey | string | required | Your Vettly API key |policyId
| | string | required | Policy ID to apply |value
| | string | '' | Controlled value |onChange
| | (value, result) => void | - | Called on value change with current moderation state |debounceMs
| | number | 500 | Milliseconds to wait before checking |showFeedback
| | boolean | true | Show built-in feedback component |blockUnsafe
| | boolean | false | Prevent additional input when content is unsafe |useFast
| | boolean | false | Use fast endpoint for sub-100ms responses |customFeedback
| | (result) => ReactNode | - | Custom feedback component |onModerationResult
| | (result) => void | - | Called with full moderation response |onModerationError
| | (error) => void | - | Called on moderation errors |
---
Image upload component with pre-upload moderation.
`tsx
apiKey="pk_live_..."
policyId="strict"
// Callbacks
onUpload={(file, result) => {
if (result.action !== 'block') {
uploadToServer(file)
}
console.log(Decision: ${result.action})Rejected: ${reason}
}}
onReject={(file, reason) => {
console.log()
}}
// Constraints
maxSizeMB={10}
acceptedFormats={['image/jpeg', 'image/png', 'image/gif', 'image/webp']}
// Behavior
showPreview={true}
blockUnsafe={true} // Prevent upload if content violates policy
disabled={false}
// Custom preview
customPreview={({ file, preview, result, onRemove }) => (
{result.action}
// Callbacks
onModerationResult={(result) => {
console.log(Image decision: ${result.decisionId})
}}
onModerationError={(error) => {
console.error('Image moderation failed:', error)
}}
className="my-upload"
/>
`
#### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| apiKey | string | required | Your Vettly API key |policyId
| | string | required | Policy ID to apply |onUpload
| | (file, result) => void | - | Called when image passes moderation and user confirms |onReject
| | (file, reason) => void | - | Called when image is rejected (validation or policy) |maxSizeMB
| | number | 10 | Maximum file size in MB |acceptedFormats
| | string[] | ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] | Accepted MIME types |showPreview
| | boolean | true | Show image preview after selection |blockUnsafe
| | boolean | true | Prevent upload if content violates policy |customPreview
| | (props) => ReactNode | - | Custom preview component |onModerationResult
| | (result) => void | - | Called with full moderation response |onModerationError
| | (error) => void | - | Called on moderation errors |
---
Video upload with frame extraction and moderation.
`tsx
apiKey="pk_live_..."
policyId="video-policy"
// Callbacks
onUpload={(file, result) => {
uploadVideoToServer(file)
console.log(Video approved: ${result.action})Video rejected: ${reason}
}}
onReject={(file, reason) => {
console.log()
}}
// Constraints
maxSizeMB={100}
maxDurationSeconds={300} // 5 minutes
acceptedFormats={['video/mp4', 'video/webm', 'video/quicktime']}
// Frame extraction
extractFramesCount={3} // Number of frames to extract for analysis
// Behavior
showPreview={true}
blockUnsafe={true}
disabled={false}
// Custom preview
customPreview={({ file, preview, duration, result, onRemove }) => (
Duration: {duration}s
Status: {result.action}
// Callbacks
onModerationResult={(result) => {
console.log(Video decision: ${result.decisionId})
}}
onModerationError={(error) => {
console.error('Video moderation failed:', error)
}}
className="my-video-upload"
/>
`
#### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| apiKey | string | required | Your Vettly API key |policyId
| | string | required | Policy ID to apply |onUpload
| | (file, result) => void | - | Called when video passes moderation |onReject
| | (file, reason) => void | - | Called when video is rejected |maxSizeMB
| | number | 100 | Maximum file size in MB |maxDurationSeconds
| | number | 300 | Maximum video duration in seconds |acceptedFormats
| | string[] | ['video/mp4', 'video/webm', 'video/quicktime'] | Accepted MIME types |extractFramesCount
| | number | 3 | Number of frames to extract for moderation |showPreview
| | boolean | true | Show video preview with thumbnail |blockUnsafe
| | boolean | true | Prevent upload if content violates policy |customPreview
| | (props) => ReactNode | - | Custom preview component |onModerationResult
| | (result) => void | - | Called with full moderation response |onModerationError
| | (error) => void | - | Called on moderation errors |
#### Features
- Drag and drop - Drop videos directly onto the upload area
- Thumbnail generation - Automatically generates video thumbnail
- Progress tracking - Shows frame extraction and analysis progress
- Duration validation - Rejects videos exceeding maximum duration
---
For custom components, use the useModeration hook directly:
`tsx
import { useModeration } from '@vettly/react'
function CustomModerationUI() {
const { result, check } = useModeration({
apiKey: 'pk_live_...',
policyId: 'community-safe',
debounceMs: 500,
enabled: true,
useFast: false,
onCheck: (response) => {
console.log(Decision: ${response.decisionId})
},
onError: (error) => {
console.error('Check failed:', error)
}
})
return (
{result.isChecking && Checking...}
{result.error && {result.error}}
{!result.isChecking && !result.error && (
$3
| Option | Type | Default | Description |
|--------|------|---------|-------------|
|
apiKey | string | required | Your Vettly API key |
| policyId | string | required | Policy ID to apply |
| debounceMs | number | 500 | Milliseconds to debounce checks |
| enabled | boolean | true | Enable/disable moderation |
| useFast | boolean | false | Use fast endpoint for sub-100ms responses |
| onCheck | (response) => void | - | Called with full CheckResponse |
| onError | (error) => void | - | Called on errors |$3
`typescript
interface UseModerationReturn {
result: {
safe: boolean // True if content passes policy
flagged: boolean // True if content is flagged for review
action: 'allow' | 'warn' | 'flag' | 'block'
categories: Array<{
category: string
score: number // 0-1 confidence score
triggered: boolean // True if threshold exceeded
}>
isChecking: boolean // True while check is in progress
error: string | null // Error message if check failed
}
check: (content: string | CheckRequest) => Promise
}
`$3
`tsx
const { check } = useModeration({ apiKey, policyId })// Check with full request object
check({
content: imageBase64,
contentType: 'image',
policyId: 'strict-images',
metadata: {
userId: 'user_123',
context: 'profile_photo'
}
})
`---
Styling & Customization
$3
Import the default stylesheet:
`tsx
import '@vettly/react/styles.css'
`$3
All components use semantic CSS classes for easy customization:
`css
/ Textarea wrapper /
.moderated-textarea-wrapper { }
.moderated-textarea { }/ Feedback states /
.moderation-feedback { }
.feedback-checking { }
.feedback-safe { }
.feedback-warn { }
.feedback-flag { }
.feedback-block { }
.feedback-error { }
/ Image upload /
.moderated-image-upload { }
.upload-area { }
.upload-icon { }
.upload-text { }
.upload-hint { }
.upload-error { }
.image-preview { }
.preview-container { }
.preview-image { }
.preview-overlay { }
.preview-info { }
.moderation-status { }
.status-allow { }
.status-warn { }
.status-flag { }
.status-block { }
.preview-actions { }
.btn-remove { }
.btn-confirm { }
/ Video upload /
.moderated-video-upload { }
.drag-over { }
.thumbnail-container { }
.video-thumbnail { }
.play-overlay { }
.play-button { }
.duration-badge { }
.processing-overlay { }
.progress-bar { }
.progress-fill { }
`$3
The default styles use border colors to indicate moderation status:
| Action | Border Color |
|--------|--------------|
| Checking | Blue (
border-blue-300) |
| Allow | Green (border-green-400) |
| Warn | Orange (border-orange-400) |
| Flag | Yellow (border-yellow-400) |
| Block | Red (border-red-400) |
| Error | Red (border-red-400) |$3
`tsx
apiKey={apiKey}
policyId={policyId}
showFeedback={false} // Disable default feedback
customFeedback={(result) => (
{result.isChecking ? (
) : result.error ? (
) : result.action === 'block' ? (
) : (
)}
)}
/>
`---
Accessibility
All components follow accessibility best practices:
$3
- Tab - Navigate between components
- Enter/Space - Activate upload buttons
- Escape - Cancel previews (when implemented)
$3
- Upload areas have
role="button" and proper focus handling
- Error messages use appropriate ARIA live regions
- Progress indicators announce status changes$3
- Feedback messages are announced as they change
- Upload areas have descriptive labels
- Error states provide clear explanations
---
Server-Side Validation
Client-side moderation is for user experience only. Always validate on the server:
`tsx
// Client-side (React)
function CommentForm() {
const [content, setContent] = useState('')
const [clientResult, setClientResult] = useState(null) return (
)
}// Server-side (API route)
import { createClient } from '@vettly/sdk'
const vettly = createClient(process.env.VETTLY_API_KEY)
export async function POST(req) {
const { content, clientDecisionId } = await req.json()
// Always validate server-side
const result = await vettly.check({
content,
policyId: 'community-safe',
metadata: { clientDecisionId } // Optional: for correlation
})
if (result.action === 'block') {
return Response.json(
{ error: 'Content blocked', decisionId: result.decisionId },
{ status: 403 }
)
}
// Store with decision ID for audit trail
await db.comments.create({
content,
moderationDecisionId: result.decisionId,
action: result.action
})
return Response.json({ success: true })
}
`---
Get Your API Key
1. Sign up at vettly.dev
2. Go to Dashboard > API Keys
3. Create and copy your publishable key (
pk_live_...`) for client-side use---
- vettly.dev - Sign up
- docs.vettly.dev - Documentation
- @vettly/sdk - Server-side SDK