Unprofane

Clerk username check.

A webhook on user.created and user.updated that replaces a bad username.

Runs in a webhook route. Every name is checked with context: "username" and the teen policy; change the policy to suit the audience. Replace the key.

// app/api/webhooks/clerk/route.ts: refuse a username at user.created / user.updated
import { Unprofane } from '@unprofane/sdk';
const unprofane = new Unprofane(process.env.UNPROFANE_API_KEY!);

/** Returns the reason a username cannot be shown, or null when it can. */
export async function usernameProblem(username: string): Promise<string | null> {
  const r = await unprofane.check({ text: username, context: 'username', policy: 'teen' });
  if (r.allowed) return null;
  return r.matches[0]?.category === 'reserved'
    ? 'That name is reserved.'
    : 'Pick a different username.';
}

export async function POST(req: Request) {
  const event = await req.json();
  if (event.type === 'user.created' || event.type === 'user.updated') {
    const problem = await usernameProblem(event.data.username ?? '');
    if (problem) await clerkClient.users.updateUser(event.data.id, { username: `user_${event.data.id.slice(-8)}` });
  }
  return new Response('ok');
}

Notes

Clerk lets the username through first, so the hook renames it a moment later; show the person a prompt to choose again. Verify the webhook signature with Clerk’s helper before this code runs.

What the response holds, and how to read a reserved name apart from a profane one, is in the API reference. The other recipes are on the username page.