In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
If you’ve followed my guides on building the Next.js newsletter form and wiring Brevo to Sanity, you know the stack that powers buildwithmatija.com. It was humming along nicely—more than 1,000 readers strong—until a traffic spike drew in a wave of Russian spam signups. reCAPTCHA v3 and Vercel bot protection slowed them down, but bad actors still slipped into Brevo. I needed a harder gate that fit the workflow I’d already shared.
Here’s the exact pipeline I added: capture browser hints, resolve and geolocate the client IP, run AbuseIPDB scoring, divert suspicious signups into a dedicated Sanity collection, and only sync clean contacts to Brevo. You can graft the same firewall onto your own setup in a weekend.
Step 1: Capture client hints alongside the form submission
The first step still happens in the browser. I want timezone, language, and the page URL so I can compare the visitor’s story with what the server learns later. Every signup surface reuses a small hook that hydrates hidden inputs.
This keeps the markup clean—each form adds hidden fields for timezone, language, and signupUrl. Those hints become useful once we cross-check them with the server-side lookups coming next.
Step 2: Resolve the signup IP and geo-enrich it
The server action already has access to the request headers, so grabbing an origin IP meant choosing the most reliable header Vercel exposes (x-forwarded-for) and falling back through the usual suspects. I then wrapped an ipinfo.io call in a helper so the logic stayed testable and reusable.
ipinfo’s free tier gives city, region, country, timezone, hostname, ISP, and even coordinates. I cache responses for an hour to avoid hammering the endpoint when the same bot keeps hitting the form. The helper quietly skips loopback addresses and logs a warning if the token is missing so deployments don’t fail silently.
Step 3: Score the IP with AbuseIPDB
The second piece of the puzzle is reputation. AbuseIPDB has a generous free quota and the API is dead simple. I wrapped it in another helper that returns a normalized object.
The abuseConfidenceScore ranges from 0–100. Anything over ~50 is sketchy, but the goal here is to use the score as a trigger—not just to log it.
Step 4: Codify the diversion threshold
Once I saw how conservative AbuseIPDB scoring is, I picked a threshold of 70 to separate obviously malicious hosts from occasional noise. I also decided that any lookup failure should be treated as suspicious and sent to manual review. That logic now lives in a small helper so the newsletter action and comment opt-in can share it.
Keeping the decision in one place makes it easy to tune the threshold later and gives me a consistent reason code (abuse-score-high versus abuse-lookup-failed) when I inspect diverted submissions.
Step 5: Extend the server action to branch on spam risk
With the helper in place, the newsletter action normalizes the IP, enriches it, runs the assessment, and only syncs to Brevo when the signup is clean. Suspicious signups now land in a separate Sanity collection for manual review.
The safe path hasn’t changed: clean contacts go into the emailSubscription collection and sync to Brevo immediately. The interesting bit is the early return—anything that failed the check is written to a spam holding area and never touches Brevo. The UI still shows a friendly “thanks” so attackers don’t get feedback.
Step 6: Persist diverted signups in their own Sanity collection
Spam that never surfaces is great, but I still want an audit trail so I can promote false positives. A second document type mirrors the primary subscription schema, adds a reason field, and gives reviewers room for notes.
This lives alongside emailSubscription in src/lib/sanity/schemaTypes/index.ts, so Sanity Studio exposes both collections. When I spot a legitimate signup stuck in the spam queue, I can copy the record into the primary collection, note the decision, and trigger Brevo manually.
Persisting the documents only takes a thin wrapper in the Sanity server helpers:
The input type extends the existing EmailSubscriptionInput, so callers can pass the same metadata regardless of the path.
Step 7: Map the attributes for Brevo (only when clean)
The clean path still feeds Brevo the attributes it expects. The difference now is that this mapper only executes after the spam gate, so Brevo never sees the quarantined contacts.
ts
// File: src/lib/brevo/brevo.tsimport { ContactsApi, ContactsApiApiKeys } from'@getbrevo/brevo'const brevoApiKey = process.env.BREVO_API_KEYconstcontactsApi: ContactsApi | null = brevoApiKey ? newContactsApi() : nullif (contactsApi && brevoApiKey) {
contactsApi.setApiKey(ContactsApiApiKeys.apiKey, brevoApiKey)
} else {
console.warn('BREVO_API_KEY environment variable is not configured; Brevo sync disabled.')
}
interfaceBrevoContact {
email: stringname?: stringsource?: stringtimezone?: stringlanguage?: stringsignupUrl?: stringipAddress?: stringgeoCity?: stringgeoRegion?: stringgeoCountry?: stringgeoLatitude?: numbergeoLongitude?: number
}
exportasyncfunctionaddContactToBrevoList(contact: BrevoContact, listId: number = 7) {
if (!contactsApi) {
console.warn('Skipping Brevo sync because BREVO_API_KEY is missing.', {
email: contact.email,
listId,
})
return {
success: true,
data: { message: 'Brevo integration disabled' },
}
}
try {
constattributes: Record<string, string> = {}
if (contact.name) {
const nameParts = contact.name.trim().split(' ')
const firstName = nameParts[0]
const lastName = nameParts.slice(1).join(' ').trim()
if (firstName) {
attributes.FIRSTNAME = firstName
}
if (lastName) {
attributes.LASTNAME = lastName
}
}
if (contact.source) {
attributes.SOURCE = contact.source
}
if (contact.timezone) {
attributes.CONTACT_TIMEZONE = contact.timezone
}
if (contact.language) {
attributes.LANGUAGE = contact.language
}
if (contact.signupUrl) {
attributes.SIGNUP_PAGE = contact.signupUrl
}
if (contact.ipAddress) {
attributes.SIGNUP_IP = contact.ipAddress
}
if (contact.geoCity) {
attributes.CITY = contact.geoCity
}
if (contact.geoRegion) {
attributes.REGION = contact.geoRegion
}
if (contact.geoCountry) {
attributes.COUNTRY = contact.geoCountry
}
if (typeof contact.geoLatitude === 'number') {
attributes.LOCATION_LAT = String(contact.geoLatitude)
}
if (typeof contact.geoLongitude === 'number') {
attributes.LOCATION_LNG = String(contact.geoLongitude)
}
const brevoContact = {
email: contact.email,
attributes,
listIds: [listId],
updateEnabled: true// This handles existing contacts gracefully
}
const result = await contactsApi.createContact(brevoContact)
return { success: true, data: result.body }
} catch (error: any) {
console.error('❌ Failed to add contact to Brevo:', {
email: contact.email,
error: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
response: error.response?.body || error.response?.data,
fullError: error
})
if (error.response?.body?.code === 'duplicate_parameter') {
console.log('Contact already exists in Brevo, this is okay')
return { success: true, data: { message: 'Contact already exists' } }
}
if (error.response?.status === 401) {
console.error('Brevo API authentication failed - check API key validity')
}
return {
success: false,
error: error.message || 'Failed to add contact to Brevo'
}
}
}
This still matches Brevo’s attribute table exactly: timezone, language, signup URL, source, IP, city, region, country, and coordinates. Pairing that with the clean Sanity record keeps my audience list pristine and ready for nurture sequences.
As a bonus, the same spam gating now protects the comment opt-in checkbox. The comment action reuses assessSubscriptionRisk, so spammy commenters don’t slip into the newsletter either.
Wrapping up
When the audience crossed four digits, the spammer traffic followed. Instead of scrapping the stack, I layered in intelligence: browser hints, ipinfo geolocation, AbuseIPDB scoring, a shared spam assessment helper, and a Sanity spam queue that keeps Brevo spotless. If you follow the same steps, your newsletter pipeline can welcome legitimate readers while silently diverting the garbage.
Let me know in the comments if you have questions, and subscribe for more practical development guides.