Framework Quick-Starts: React, Vue and Laravel Forms
On a framework build there is no plugin to install and no settings screen to tick. The form is thirty lines in a component, it posts to an endpoint you wrote, and whatever protection it has is protection you put there yourself.
That sounds like more work. It is actually the better position to be in: you own every step between the submit button and the database, so you can put the check exactly where it belongs instead of wherever a plugin author decided to expose a hook.
Here are the patterns for the three stacks we see most, and the details that separate a moderation layer that works from one that generates support tickets.
The Rule That Survives Every Framework
Score on the server, at the point where the submission is actually processed.
Client-side checks are worth having for the experience: instant feedback, no round trip for obvious cases. They are not protection. Anything running in the browser can be skipped by anyone posting straight to your endpoint, which is exactly what a bot does. OWASP has said this about client-side validation for two decades and it has not stopped being true.
So: optional client check for UX, mandatory server check for security. Everything below is the server half.
React and Next.js
In an App Router project the natural home is the route handler that receives the submission, before anything is persisted or emailed.
// app/api/contact/route.js
import { NextResponse } from "next/server";
export async function POST(request) {
const { email, message } = await request.json();
let scores = null;
try {
const res = await fetch("https://api.babelshield.com/v1/score", {
method: "POST",
headers: {
Authorization: Bearer ${process.env.BABELSHIELD_SECRET_KEY},
"Content-Type": "application/json",
},
body: JSON.stringify({ text: message }),
signal: AbortSignal.timeout(3000),
});
scores = await res.json();
} catch (err) {
// Fail open: never lose a real enquiry to a network blip.
console.warn("moderation unavailable", err);
}
if (scores) {
console.info("submission scored", { scores });
if (scores.spam > 0.8 || scores.hate > 0.5 || scores.junk > 0.85) {
// Accept-and-drop: the sender gets a normal success response,
// nothing reaches your systems.
return NextResponse.json({ ok: true });
}
}
await deliver({ email, message });
return NextResponse.json({ ok: true });
}
If you are using Server Actions rather than a route handler, the shape is identical. The scoring call sits at the top of the action, before the write.
Vue and Nuxt
Nuxt's server routes give you the same seam. The defineEventHandler body runs on the server only, so the secret key never reaches the bundle.
// server/api/contact.post.js
export default defineEventHandler(async (event) => {
const { email, message } = await readBody(event);
const config = useRuntimeConfig();
let scores = null;
try {
scores = await $fetch("https://api.babelshield.com/v1/score", {
method: "POST",
headers: { Authorization: Bearer ${config.babelshieldSecretKey} },
body: { text: message },
timeout: 3000,
});
} catch (err) {
console.warn("moderation unavailable", err); // fail open
}
if (scores && (scores.spam > 0.8 || scores.hate > 0.5)) {
return { ok: true }; // accept and drop
}
await deliver({ email, message });
return { ok: true };
});
On a plain Vue SPA talking to an Express or Fastify API, move the same block into the route middleware. The principle does not change: the check belongs in the process that writes the record.
Laravel
Laravel gives you somewhere tidier than a controller. A rule object slots the check into validation itself, which means it composes with everything else and the error surfaces through the normal error bag.
<?php
// app/Rules/CleanSubmission.php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class CleanSubmission implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
$scores = Http::withToken(config('services.babelshield.key'))
->timeout(3)
->post('https://api.babelshield.com/v1/score', ['text' => $value])
->json();
} catch (\Throwable $e) {
Log::warning('Babelshield unreachable', ['error' => $e->getMessage()]);
return; // fail open
}
Log::info('submission scored', ['attribute' => $attribute, 'scores' => $scores]);
if (($scores['spam'] ?? 0) > 0.8
|| ($scores['hate'] ?? 0) > 0.5
|| ($scores['junk'] ?? 0) > 0.85) {
$fail('Your message could not be processed. Please review it and try again.');
}
}
}
Then it is one line in the form request:
public function rules(): array
{
return [
'email' => ['required', 'email'],
'message' => ['required', 'string', 'max:5000', new CleanSubmission()],
];
}
(Endpoints, field names and thresholds throughout are illustrative. Check the current Babelshield docs and tune to your own forms.)
The Four Details That Decide Whether This Holds Up
The API call is the easy part. These are the things that bite in production.
Fail open, always. Every example above returns without blocking when the service is unreachable. A moderation outage should degrade to "no moderation", never to "no submissions". This is the single most important line in the integration and the one most often written the other way round.
Keep the timeout tight. Three seconds is generous. Your form's perceived speed is a conversion input, and a check that occasionally adds five seconds will cost you more than the spam it catches.
Log every score, not just the blocks. The scores on your legitimate traffic are what let you set thresholds with evidence instead of instinct, and they are what let you answer "why was this submission rejected" six weeks later. Log the scores and the decision; you do not need to keep the message body to do it.
Make it injectable so it is testable. Wrap the HTTP call in a small client class and resolve it from the container, so your test suite can swap in a fake that returns fixed scores. In Laravel, Http::fake() covers it out of the box. Otherwise you end up with a test suite that either hits a live API or silently skips the most important branch in your validation.
Where To Put It When You Have a Queue
Plenty of framework apps accept a submission and hand it to a job. Score before the write, not inside the job, because the whole point is to stop the record existing. If the endpoint genuinely cannot afford the round trip, persist with a pending_moderation status, keep it out of every read path, and let the job flip it to accepted or rejected. What you must not do is publish, notify or push to a CRM first and moderate afterwards, because by then the ripple has already started.
Rate limiting is the other thing worth pairing here. Content scoring tells you whether a message is junk; it does not blunt a raw flood. Framework-level throttling, or a bot layer at the edge, handles the volume while the content layer handles the substance.
The Payoff
A hand-built form gives you something the plugin ecosystem cannot: the check runs exactly where you decide, on exactly the fields that carry meaning, with a failure mode you chose. The visitor experiences none of it: no widget, no puzzle, no extra request in their path.
Thirty lines in the right place, and the junk stops before it becomes a record.
References
OWASP. Guidance on server-side validation and not trusting client-side controls.
Laravel. Validation documentation: custom rule objects and the ValidationRule contract.
Next.js. Route handlers and Server Actions documentation.
Nuxt. Server routes and defineEventHandler documentation.