Next.js middleware for content moderation. App Store compliance for API routes.
npm install @vettly/nextjsNext.js integration for App Store Guideline 1.2 compliance. Policy-governed decisions for App Router, Pages Router, and Middleware.
Apple requires every iOS app with user-generated content to implement four things. This package handles all four in Next.js:
| Requirement | Guideline | Next.js Integration |
|-------------|-----------|---------------------|
| Content filtering | 1.2.1 | moderateRoute(), moderateMiddleware() |
| User reporting | 1.2.2 | Re-exported SDK client (POST /v1/reports) |
| User blocking | 1.2.3 | Re-exported SDK client (POST /v1/blocks) |
| Audit trail | — | result.decisionId on every decision |
``typescript
// app/api/comments/route.ts
import { moderateRoute } from '@vettly/nextjs'
import { NextResponse } from 'next/server'
export const POST = moderateRoute({
apiKey: process.env.VETTLY_API_KEY!,
policyId: 'app-store',
field: 'content',
handler: async (req) => {
const body = await req.json()
await db.comments.create({ data: body })
return NextResponse.json({ success: true })
}
})
`
- Edge-ready - Works in Edge Middleware and Edge Runtime
- App Router support - Route Handlers with built-in moderation
- Middleware protection - Protect multiple routes with a single config
- Audit trail - Every decision linked to request for compliance
`bash`
npm install @vettly/nextjs @vettly/sdk
Protect a single API route:
`typescript
// app/api/comments/route.ts
import { moderateRoute } from '@vettly/nextjs'
import { NextResponse } from 'next/server'
export const POST = moderateRoute({
apiKey: process.env.VETTLY_API_KEY!,
policyId: 'community-safe',
field: 'content',
handler: async (req) => {
const body = await req.json()
// Content passed moderation
await db.comments.create({ data: body })
return NextResponse.json({ success: true })
}
})
`
Protect multiple routes at once:
`typescript
// middleware.ts
import { moderateMiddleware } from '@vettly/nextjs'
export default moderateMiddleware({
apiKey: process.env.VETTLY_API_KEY!,
policyId: 'community-safe',
field: async (req) => {
const body = await req.json()
return body.content
}
})
export const config = {
matcher: '/api/comments/:path*'
}
`
---
`typescript
import { moderateMiddleware } from '@vettly/nextjs'
export default moderateMiddleware({
// Required
apiKey: process.env.VETTLY_API_KEY!,
policyId: 'community-safe',
field: async (req) => {
const body = await req.json()
return body.content
},
// Optional: Custom handlers for each action
onBlock: (req, result) => {
return NextResponse.json(
{
error: 'Content blocked',
decisionId: result.decisionId,
categories: result.categories.filter(c => c.triggered)
},
{ status: 403 }
)
},
onFlag: (req, result) => {
// Log flagged content but allow through
console.log(Flagged: ${result.decisionId})
// Return undefined to continue, or NextResponse to override
return undefined
},
onWarn: (req, result) => {
// Minor concern - add header but allow
const response = NextResponse.next()
response.headers.set('X-Content-Warning', 'true')
return response
}
})
`
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| apiKey | string | Yes | Your Vettly API key |policyId
| | string | No | Policy ID (default: 'moderate') |field
| | string \| function | Yes | Field path or async extractor function |onBlock
| | function | No | Custom response for blocked content |onFlag
| | function | No | Custom handling for flagged content |onWarn
| | function | No | Custom handling for warned content |
---
For App Router API routes:
`typescript
// app/api/posts/route.ts
import { moderateRoute } from '@vettly/nextjs'
import { NextResponse } from 'next/server'
export const POST = moderateRoute({
apiKey: process.env.VETTLY_API_KEY!,
policyId: 'social-media',
field: 'content',
handler: async (req) => {
const body = await req.json()
// Save with decision ID for audit trail
// Note: moderationResult is available via closure in onBlock
await db.posts.create({
content: body.content,
authorId: body.authorId
})
return NextResponse.json({ success: true })
},
onBlock: (req, result) => {
return NextResponse.json(
{
error: 'Post content violates community guidelines',
decisionId: result.decisionId,
triggeredCategories: result.categories
.filter(c => c.triggered)
.map(c => c.category)
},
{ status: 403 }
)
}
})
`
---
For complex request structures:
`typescript${body.title}\n\n${body.body}
// Combine multiple fields
export const POST = moderateRoute({
apiKey: process.env.VETTLY_API_KEY!,
policyId: 'social-media',
field: async (req) => {
const body = await req.json()
// Combine title and body for moderation
return `
},
handler: async (req) => {
// ...
}
})
---
Use middleware with route matchers:
`typescript
// middleware.ts
import { moderateMiddleware } from '@vettly/nextjs'
export default moderateMiddleware({
apiKey: process.env.VETTLY_API_KEY!,
policyId: 'user-content',
field: async (req) => {
const body = await req.json()
return body.content || body.text || body.message || ''
}
})
export const config = {
matcher: [
'/api/comments/:path*',
'/api/posts/:path*',
'/api/reviews/:path*',
'/api/messages/:path*'
]
}
`
---
Skip moderation for certain requests:
`typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { ModerationClient } from '@vettly/nextjs'
const client = new ModerationClient({ apiKey: process.env.VETTLY_API_KEY! })
export async function middleware(req: NextRequest) {
// Skip GET requests
if (req.method === 'GET') {
return NextResponse.next()
}
// Skip trusted users (example: admin role in JWT)
const token = req.cookies.get('auth-token')
if (token && isAdmin(token.value)) {
return NextResponse.next()
}
// Moderate other requests
try {
const body = await req.json()
const result = await client.check({
content: body.content,
policyId: 'community-safe'
})
if (result.action === 'block') {
return NextResponse.json(
{ error: 'Content blocked', decisionId: result.decisionId },
{ status: 403 }
)
}
} catch (error) {
// Fail open
console.error('Moderation error:', error)
}
return NextResponse.next()
}
export const config = {
matcher: '/api/:path*'
}
`
---
Moderate in Server Actions:
`typescript
// app/actions/post.ts
'use server'
import { ModerationClient } from '@vettly/nextjs'
import { revalidatePath } from 'next/cache'
const client = new ModerationClient({ apiKey: process.env.VETTLY_API_KEY! })
export async function createPost(formData: FormData) {
const content = formData.get('content') as string
// Check content
const result = await client.check({
content,
policyId: 'community-safe'
})
if (result.action === 'block') {
return {
error: 'Content violates community guidelines',
decisionId: result.decisionId
}
}
// Save with audit trail
await db.posts.create({
content,
moderationDecisionId: result.decisionId,
moderationAction: result.action
})
revalidatePath('/posts')
return { success: true }
}
`
---
The middleware fails open by default:
`typescript
export default moderateMiddleware({
apiKey: process.env.VETTLY_API_KEY!,
policyId: 'community-safe',
field: 'content',
// On any error, request continues (fail open)
// This is the default behavior
})
`
To fail closed, wrap in try/catch:
`typescript
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { ModerationClient } from '@vettly/nextjs'
const client = new ModerationClient({ apiKey: process.env.VETTLY_API_KEY! })
export async function middleware(req: NextRequest) {
try {
const body = await req.json()
const result = await client.check({
content: body.content,
policyId: 'community-safe'
})
if (result.action === 'block') {
return NextResponse.json({ error: 'Blocked' }, { status: 403 })
}
return NextResponse.next()
} catch (error) {
// Fail closed - reject if moderation unavailable
console.error('Moderation unavailable:', error)
return NextResponse.json(
{ error: 'Content moderation unavailable' },
{ status: 503 }
)
}
}
`
---
The package is Edge-compatible:
`typescript
// app/api/comments/route.ts
import { moderateRoute } from '@vettly/nextjs'
export const runtime = 'edge'
export const POST = moderateRoute({
apiKey: process.env.VETTLY_API_KEY!,
policyId: 'community-safe',
field: 'content',
handler: async (req) => {
// Works in Edge Runtime
const body = await req.json()
// ...
}
})
`
---
The SDK client is re-exported for convenience:
`typescript
import { ModerationClient, moderateRoute, moderateMiddleware } from '@vettly/nextjs'
// Use helpers for simple cases
export const POST = moderateRoute({ ... })
// Use client directly for complex flows
const client = new ModerationClient({ apiKey: '...' })
const result = await client.check({ content, policyId })
`
---
Full TypeScript support:
`typescript
import { moderateRoute } from '@vettly/nextjs'
import type { NextRequest } from 'next/server'
import type { CheckResponse } from '@vettly/sdk'
export const POST = moderateRoute({
apiKey: process.env.VETTLY_API_KEY!,
policyId: 'community-safe',
field: 'content',
handler: async (req: NextRequest) => {
// Typed request
},
onBlock: (req: NextRequest, result: CheckResponse) => {
// Typed result with decisionId, action, categories, etc.
}
})
``
---
1. Sign up at vettly.dev
2. Go to Dashboard > API Keys
3. Create and copy your key
---
- vettly.dev - Sign up
- docs.vettly.dev - Documentation
- @vettly/sdk - Core SDK
- @vettly/react - React components