SFW API
Menu

Guides

Moderate image uploads in Next.js before they are stored

Last updated 2026-11-09

Users upload avatars, post images and listing photos; you want nudity caught before the file is written to storage and shown to anyone else. This guide adds that check to a Next.js app with a server action: the file goes from the form to the SFW API, you get a level from 0 (Everyday) to 3 (Explicit) and a blocked verdict against your own policy, and only approved files reach your storage. The same code works in a route handler.

What you need

Put the key in .env.local as SFWAPI_API_KEY=sk_live_.... It is read on the server only; never prefix it with NEXT_PUBLIC_.

The server action

The action receives the form data, rejects anything that is not an image or is too large, sends the bytes to the API and decides what to do with the result. Storage is your own code (S3, Vercel Blob, a database); the example calls it put.

app/upload/actions.ts
'use server';

import { SfwApi, SfwApiError } from '@sfwapi/sdk';
import { put } from '@/lib/storage';

const api = new SfwApi({ apiKey: process.env.SFWAPI_API_KEY! });
const MAX_BYTES = 8 * 1024 * 1024; // free tier limit; paid accounts take 32 MB
const POLICY = { block_level: 2, block_sex_objects: true } as const;

export type UploadResult = { ok: true; url: string; needsReview: boolean } | { ok: false; reason: string };

export async function uploadImage(formData: FormData): Promise<UploadResult> {
  const file = formData.get('file');
  if (!(file instanceof File) || !file.type.startsWith('image/')) return { ok: false, reason: 'Please choose an image.' };
  if (file.size > MAX_BYTES) return { ok: false, reason: 'Images can be up to 8 MB.' };

  const bytes = Buffer.from(await file.arrayBuffer());
  try {
    const r = await api.analyzeFile(bytes, { policy: POLICY });
    if (r.policy?.blocked) return { ok: false, reason: 'This image cannot be uploaded here.' };

    const grey = Object.values(r.nudity.scores).some((s) => s > 35 && s < 65);
    const url = await put(bytes, file.type);
    return { ok: true, url, needsReview: r.nudity.level >= 1 || grey };
  } catch (e) {
    if (e instanceof SfwApiError) {
      if (e.status === 413 || e.status === 415 || e.code === 'decode_failed') return { ok: false, reason: 'That file is not an image we can read.' };
      console.error('sfwapi', e.status, e.code, e.requestId);
      return { ok: false, reason: 'The upload check is unavailable right now. Please try again in a moment.' };
    }
    throw e;
  }
}

The form is a small client component so it can show the pending state and the reason for a rejection:

app/upload/upload-form.tsx
'use client';

import { useActionState } from 'react';
import { uploadImage, type UploadResult } from './actions';

export function UploadForm() {
  const [state, action, pending] = useActionState(async (_prev: UploadResult | null, fd: FormData) => uploadImage(fd), null);
  return (
    <form action={action}>
      <input type="file" name="file" accept="image/*" required />
      <button type="submit" disabled={pending}>
        {pending ? 'Checking…' : 'Upload'}
      </button>
      {state && !state.ok ? <p role="alert">{state.reason}</p> : null}
    </form>
  );
}

That is the whole integration. The rest of this guide explains the decisions in it.

Choosing the policy

The API returns scores, but you do not have to turn them into a decision yourself. Send a policy and the response carries policy.blocked, computed with the same engine that moderates Discord servers through SfwBot. The one field that matters most is block_level:

block_levelBlocksTypical site
1swimwear, lingerie and anything abovecommunities with minors, professional networks
2implied nudity and explicit contentthe default for most apps: bare skin is fine, nudity is not
3explicit content onlyadult-friendly platforms with a hard line

block_sex_objects: true also blocks images where an adult toy or object is detected, independent of nudity. If your users upload drawings, the art option decides how illustrated content is judged; same_as_photos (the default) treats it like a photo. The policies page lists every option and the combinations that work for common communities.

Reading the response

A practical pattern for user uploads: store files at level 0 straight away, store level 1 and grey-zone files but mark them needsReview so a moderator sees them in a queue, and reject blocked files. The user uploads use case describes the queue in more detail.

Handling errors

Every failure has one shape: an error object with type, code, message, doc_url and request_id. The SDK throws SfwApiError with status, code, message and requestId. The ones you will meet in an upload flow:

Failed requests are never charged, so a retry costs nothing extra. Log status, code and requestId; never log the file, its name or a hash of it. The errors page has every code.

Without the SDK

If you prefer no dependency, the request is a multipart POST. The policy goes in as a JSON string:

lib/sfwapi.ts
export async function analyze(bytes: Buffer, type: string) {
  const body = new FormData();
  body.set('file', new Blob([bytes], { type }));
  body.set('policy', JSON.stringify({ block_level: 2, block_sex_objects: true }));
  const res = await fetch('https://api.sfwapi.dev/v1/analyze', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SFWAPI_API_KEY}` }, body });
  const json = await res.json();
  if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message} (${json.error.request_id})`);
  return json as { nudity: { level: 0 | 1 | 2 | 3; scores: Record<string, number> }; policy?: { blocked: boolean } };
}

If the image already has a public URL (a CDN, an existing bucket), send { "url": "https://…" } as JSON instead and the API downloads it; that saves your server the upload bandwidth. Private and local addresses are refused.

Costs and limits

One credit per image, so 500 uploads a month are free. A 10,000 credit pack is $10 and never expires; at 50,000 images a month the price is $0.80 per 1,000. Free accounts can send images up to 8 MB; paid accounts 32 MB, plus GIFs and videos. Images above 10 MB cost one extra credit per started 10 MB. If you resize client-side before upload (most apps do, to save storage), you will never hit either. Full details on the pricing page and under rate limits and credits.

Frequently asked questions

Does the image leave my server?
Yes, the bytes are sent to the SFW API over TLS, analysed in memory on servers in Germany and discarded when the response is returned. Nothing is stored, no URL, no hash, no file name.
Can I run the check in the browser instead?
No. The API key must stay on the server. Browsers call your server action or route handler, which calls the API. The free tier is enough to test the flow end to end.
What does a check cost?
One credit per image, so 500 uploads a month fit in the free tier. Failed requests, including rejected files, are never charged.
How do I handle GIF uploads?
The same call works. A GIF is sampled frame by frame (5 frames by default, one credit each) and the level is the maximum over the frames. Set maxFrames to control the cost.

Related

Next steps

Read the quickstart to get your first result in two minutes, see pricing for the free tier and prepaid packs, or get a free API key and start with your own images.