The Dropndot Email Subscriptions & Newsletters plugin makes it straightforward and secure to implement a headless email newsletter system in Next.js (App Router). To protect your WordPress backend, the browser never communicates with WordPress directly; instead, a Next.js API route acts as a secure intermediary. When a user submits their email on the frontend, Next.js validates the request, verifies reCAPTCHA, and proxies the payload to the plugin’s REST API endpoint (POST /wp-json/dropemsu-manager/v1/subscriptions). For double opt-in setups, WordPress sends a confirmation email containing a unique token link. When the user clicks the link, they are directed to Next.js (/confirm?token=…), where a server component validates the token against WordPress to activate the subscription. Secondary actions like unsubscribing or resubscribing follow the exact same secure, token-based pattern using dedicated route handlers and server-only helpers.
Plugin link: https://wordpress.org/plugins/dropndot-email-subscriptions-newsletters/
1. What this does
Browser
│ POST /api/newsletter { email, recaptchaToken }
▼
Next.js Route Handler (server) — src/app/api/newsletter/route.ts
│ verifies reCAPTCHA, then proxies
▼
WordPress REST API /wp-json/dropemsu-manager/v1/subscriptions
│ creates a "pending" row, emails a confirmation link
▼
Subscriber clicks the email link
▼
GET /confirm?token=... — src/app/(site)/confirm/page.tsx (Server Component)
│ POST /subscriptions/confirm { token }
▼
WordPress marks the row "active", sends a welcome email
Unsubscribe and resubscribe follow the identical pattern — each is a token-bearing link WordPress emails to the
subscriber, landing on src/app/(site)/unsubscribe/page.tsx or
src/app/(site)/resubscribe/page.tsx.
Non-negotiable rule (already enforced in this codebase): the browser never talks to WordPress
directly. Every call to wp-json/... happens from a Next.js server context
(src/lib/wordpress/newsletter.ts, always imported with import "server-only"), never from
client-side fetch. The only endpoint the browser calls is same-origin /api/newsletter.
2. WordPress plugin configuration
In wp-admin → Dropndot Newsletter → Settings, under “Frontend Integration”:
| Setting | Value used by this project | Notes |
|---|---|---|
| Enable REST API | ✅ On | Without this, register_rest_routes() never runs and every
/wp-json/dropemsu-manager/v1/* call 404s. |
| Enable Frontend Integration | ✅ On | Switches every emailed link (confirm/unsubscribe/resubscribe) from a WordPress-rendered page to this Next.js app’s routes. |
| Frontend Base URL | This site’s production URL | No trailing slash needed — the plugin trims it. |
| Confirmation Path | /confirm | Matches src/app/(site)/confirm/page.tsx. |
| Unsubscribe Path | /unsubscribe | Matches src/app/(site)/unsubscribe/page.tsx. |
| Resubscribe Path | /resubscribe (plugin default) | Matches src/app/(site)/resubscribe/page.tsx. Comes from the
dropemsu_resubscribe_path option / dropemsu_resubscribe_url filter, defaulting to
/resubscribe. |
| Require Email Confirmation | ✅ On | Double opt-in — this is why /confirm exists. |
| Token Expiry (days) | 7 (default) | Confirmation tokens older than this are rejected with “Confirmation token has expired”. |
| Trusted Frontend Secret | Matches NEWSLETTER_TRUSTED_PROXY_SECRET | Optional — see §5 and §12. |
3. REST API contract (/wp-json/dropemsu-manager/v1)
Four public endpoints (no WordPress auth) are used by this project:
POST /subscriptions— create a subscriptionPOST /subscriptions/confirm— double opt-in confirmationPOST /subscriptions/unsubscribePOST /subscriptions/resubscribe
The plugin also exposes admin CRUD routes gated by manage_options — GET /subscriptions,
GET/PUT/DELETE /subscriptions/{id} — this project does not proxy those to the browser.
Create — request { "email": "...", "name"?: "..." }, success 201 returns
the full subscription row with status: "pending" | "active". id comes back as a
string, not a number (src/types/newsletter.ts documents this explicitly). Failure is
400 with { code, message }.
Confirm / Unsubscribe / Resubscribe — request { "token": "..." }, success
200 returns { success: true, message, subscription }. Resubscribe can legitimately return
success: true with subscription.status === "pending" when double opt-in is on — the
resubscribe page branches its copy on this (§11).
All WordPress-side failures serialize as { code, message } (a WP_Error), which the client
layer sanitizes before it ever reaches a response.
4. Environment variables
From .env.example — the newsletter feature uses:
# Server-only
WORDPRESS_URL= # e.g. https://wp.example.com — base for the REST namespace
RECAPTCHA_SECRET_KEY= # reCAPTCHA v3 server-side verification
NEWSLETTER_TRUSTED_PROXY_SECRET= # optional, must match WP's "Trusted Frontend Secret"
# Public (browser-safe by design)
NEXT_PUBLIC_RECAPTCHA_SITE_KEY= # reCAPTCHA v3 widget
WORDPRESS_URL and RECAPTCHA_SECRET_KEY are read through requireServerEnv()
(src/lib/env.ts), which throws immediately if the variable is missing rather than silently proceeding
with undefined:
// src/lib/env.ts
import "server-only";
export function requireServerEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required server environment variable: ${name}`);
}
return value;
}
5. src/lib/wordpress/newsletter.ts — the only module that knows about WordPress
Everything else in the app calls these four exported functions. It composes two shared low-level helpers
(fetchWithTimeout / safeParseJson from src/lib/wordpress/httpClient.ts) and the
error-safety filter from src/lib/wordpress/errorMessage.ts.
import "server-only";
import { requireServerEnv } from "@/lib/env";
import {
isSafeWordPressErrorCode,
isSafeWordPressErrorMessage,
} from "@/lib/wordpress/errorMessage";
import { GENERIC_FORM_ERROR } from "@/lib/wordpress/formSubmission";
import { fetchWithTimeout, safeParseJson } from "@/lib/wordpress/httpClient";
import type { ApiError, ApiResponse } from "@/types/api";
import type {
NewsletterConfirmPayload,
NewsletterResubscribePayload,
NewsletterSubscribePayload,
NewsletterSubscription,
NewsletterUnsubscribePayload,
} from "@/types/newsletter";
const NEWSLETTER_NAMESPACE = "/wp-json/dropemsu-manager/v1";
const REQUEST_TIMEOUT_MS = 15_000;
const SUBSCRIBE_ERROR_FALLBACK: ApiError = {
success: false,
code: "newsletter_subscribe_failed",
message: "We couldn't subscribe you right now. Please try again later.",
};
const CONFIRM_ERROR_FALLBACK = "This confirmation link is invalid or has expired.";
const UNSUBSCRIBE_ERROR_FALLBACK = "This unsubscribe link is invalid or has expired.";
const RESUBSCRIBE_ERROR_FALLBACK = "This resubscribe link is invalid or has expired.";
export type NewsletterSubscribeResult = {
status: number;
body: ApiResponse;
};
export type NewsletterTokenActionResult =
| { status: number; success: true; message: string; subscription: NewsletterSubscription }
| { status: number; success: false; message: string };
function getNewsletterApiBase(): string {
return `${requireServerEnv("WORDPRESS_URL")}${NEWSLETTER_NAMESPACE}`;
}
// WordPress only sees this Next.js server's IP, not the visitor's, since subscribe
// requests are proxied server-side. When NEWSLETTER_TRUSTED_PROXY_SECRET is configured
// (matching the WP plugin's "Trusted Frontend Secret" setting), forward the real visitor
// IP so the plugin's ipinfo lookup resolves the correct country/region.
function getTrustedProxyHeaders(clientIp?: string): Record<string, string> {
const secret = process.env.NEWSLETTER_TRUSTED_PROXY_SECRET;
if (!clientIp || !secret) return {};
return {
"X-Forwarded-For": clientIp,
"X-Dropndot-Proxy-Secret": secret,
};
}
export async function subscribeToNewsletter(
payload: NewsletterSubscribePayload,
origin: string,
clientIp?: string,
): Promise<NewsletterSubscribeResult> { /* ... */ }
export async function confirmNewsletterSubscription(token: string): Promise<NewsletterTokenActionResult> { /* ... */ }
export async function unsubscribeFromNewsletter(token: string): Promise<NewsletterTokenActionResult> { /* ... */ }
export async function resubscribeToNewsletter(token: string): Promise<NewsletterTokenActionResult> { /* ... */ }
Shared low-level pieces
// src/lib/wordpress/httpClient.ts
import "server-only";
export async function fetchWithTimeout(
input: string,
init: RequestInit,
timeoutMs: number,
): Promise<Response> {
return fetch(input, { ...init, signal: AbortSignal.timeout(timeoutMs) });
}
// Returns null instead of throwing on an invalid/empty body.
export async function safeParseJson<TData>(response: Response): Promise<TData | null> {
try {
return (await response.json()) as TData;
} catch {
return null;
}
}
// src/lib/wordpress/errorMessage.ts
import "server-only";
const MAX_SAFE_MESSAGE_LENGTH = 200;
const SAFE_CODE_PATTERN = /^[a-z0-9_]{1,64}$/;
const UNSAFE_MESSAGE_PATTERNS = [
/<[a-z][\s\S]*>/i,
/\/(?:var|home|usr|etc|srv)\//i,
/[a-z]:\\/i,
/\.(?:php|js|ts|py|rb|java|sql)\b/i,
/\b(?:sqlstate|fatal error|stack trace|traceback|exception|warning:|notice:|deprecated:|uncaught)\b/i,
/\bat\s+\S+:\d+\b/i,
];
export function isSafeWordPressErrorCode(code: string): boolean {
return SAFE_CODE_PATTERN.test(code);
}
export function isSafeWordPressErrorMessage(message: string): boolean {
const trimmed = message.trim();
if (trimmed.length === 0 || trimmed.length > MAX_SAFE_MESSAGE_LENGTH) return false;
return !UNSAFE_MESSAGE_PATTERNS.some((pattern) => pattern.test(trimmed));
}
GENERIC_FORM_ERROR
({ success: false, code: "form_unexpected_error", message: "Something went wrong. Please try again later." })
is imported from src/lib/wordpress/formSubmission.ts — one shared network-failure fallback across every
WordPress-backed form in the app (contact, quote, newsletter).
6. src/types/newsletter.ts and src/types/api.ts
// src/types/newsletter.ts
export type NewsletterSubscriptionStatus = "pending" | "active" | "inactive";
// Note: the REST API returns `id` as a string (e.g. "12"), unlike the docs' JSON example.
export type NewsletterSubscription = {
id: string;
email: string;
name: string | null;
status: NewsletterSubscriptionStatus;
country: string | null;
countryCode: string | null;
region: string | null;
createdAt: string;
updatedAt: string;
confirmedAt: string | null;
};
// POST {WORDPRESS_URL}/wp-json/dropemsu-manager/v1/subscriptions
export type NewsletterSubscribePayload = { email: string; name?: string };
// POST {WORDPRESS_URL}/wp-json/dropemsu-manager/v1/subscriptions/confirm
export type NewsletterConfirmPayload = { token: string };
// POST {WORDPRESS_URL}/wp-json/dropemsu-manager/v1/subscriptions/unsubscribe
export type NewsletterUnsubscribePayload = { token: string };
// POST {WORDPRESS_URL}/wp-json/dropemsu-manager/v1/subscriptions/resubscribe
export type NewsletterResubscribePayload = { token: string };
// src/types/api.ts — shared response envelope for every WordPress-backed form
export type ApiSuccess = { success: true; message: string };
export type ApiError = { success: false; code: string; message: string };
export type ApiResponse = ApiSuccess | ApiError;
7. src/lib/newsletter/schema.ts — Zod validation
import { z } from "zod";
export const NewsletterSubscribeSchema = z.object({
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"),
});
export type NewsletterSubscribeValues = z.infer<typeof NewsletterSubscribeSchema>;
// Wire contract for POST /api/newsletter — the email plus the reCAPTCHA token
// fetched at submit time (not a user-facing form field).
export const NewsletterRequestSchema = NewsletterSubscribeSchema.extend({
recaptchaToken: z.string().min(1, "Verification failed. Please try again."),
});
export type NewsletterRequestValues = z.infer<typeof NewsletterRequestSchema>;
// Shared by /confirm and /unsubscribe — both pages receive `?token=` from a WordPress email link.
export const NewsletterTokenSchema = z.object({
token: z.string().trim().min(1, "This link is missing a valid token."),
});
export type NewsletterTokenValues = z.infer<typeof NewsletterTokenSchema>;
src/lib/newsletter/metadata.ts holds the title/description/path constants each token page’s
generateMetadata() reads from (NEWSLETTER_CONFIRM_TITLE,
NEWSLETTER_CONFIRM_PATH, and the unsubscribe/resubscribe equivalents).
8. src/app/api/newsletter/route.ts — the only endpoint the browser calls
import type { NextRequest } from "next/server";
import { NewsletterRequestSchema } from "@/lib/newsletter/schema";
import { verifyRecaptchaToken } from "@/lib/recaptcha/verify";
import { subscribeToNewsletter } from "@/lib/wordpress/newsletter";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const NEWSLETTER_RECAPTCHA_ACTION = "newsletter_subscribe";
// Vercel sets x-forwarded-for to the real visitor IP on every incoming request;
// NextRequest no longer exposes .ip directly (removed in Next.js 15).
function getClientIp(request: NextRequest): string | undefined {
const forwardedFor = request.headers.get("x-forwarded-for");
const firstForwarded = forwardedFor?.split(",")[0]?.trim();
return firstForwarded || request.headers.get("x-real-ip") || undefined;
}
export async function POST(request: NextRequest): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ success: false, message: "Invalid request body." }, { status: 400 });
}
const parsed = NewsletterRequestSchema.safeParse(body);
if (!parsed.success) {
const message = parsed.error.issues[0]?.message ?? "Invalid email address.";
return Response.json({ success: false, message }, { status: 422 });
}
const isHuman = await verifyRecaptchaToken(
parsed.data.recaptchaToken,
NEWSLETTER_RECAPTCHA_ACTION,
);
if (!isHuman) {
return Response.json(
{ success: false, message: "We couldn't verify your submission. Please try again." },
{ status: 400 },
);
}
const result = await subscribeToNewsletter(
{ email: parsed.data.email },
request.nextUrl.origin,
getClientIp(request),
);
return Response.json(result.body, { status: result.status });
}
9. reCAPTCHA v3 — src/lib/recaptcha/verify.ts and src/lib/recaptcha/client.ts
// src/lib/recaptcha/verify.ts
import "server-only";
import { requireServerEnv } from "@/lib/env";
const SITEVERIFY_URL = "https://www.google.com/recaptcha/api/siteverify";
const REQUEST_TIMEOUT_MS = 8_000;
const MIN_SCORE = 0.5; // Google's recommended default cutoff (0.0 = bot, 1.0 = human)
export async function verifyRecaptchaToken(
token: string,
expectedAction: string,
): Promise<boolean> {
if (!token) return false;
const secretKey = requireServerEnv("RECAPTCHA_SECRET_KEY");
let response: Response;
try {
response = await fetch(SITEVERIFY_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ secret: secretKey, response: token }),
cache: "no-store",
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch { return false; }
let body: unknown;
try { body = await response.json(); }
catch { return false; }
if (!isSiteVerifyResponse(body) || !body.success || body.action !== expectedAction) {
return false;
}
return (body.score ?? 0) >= MIN_SCORE;
}
// src/lib/recaptcha/client.ts (key exports)
// Injects the reCAPTCHA script on demand (e.g. on first form focus) instead of
// loading it globally on every page, so pages without a guarded form never pay
// for the unused bytes.
export function preloadRecaptchaScript(): Promise<void> { /* ... */ }
// Executes an invisible reCAPTCHA v3 check and returns a token to verify server-side.
export async function getRecaptchaToken(action: string): Promise<string | null> { /* ... */ }
10. src/components/common/Newsletter.tsx — the subscribe form (the only Client Component in this
feature)
State machine: "idle" | "submitting" | "success" | "error". On success the state resets to
"idle" after 6 seconds.
Key submit handler (simplified):
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
// Zod's schema-builder classes are ~30KB of JS most visitors never trigger —
// deferring the import here keeps them out of every page's initial bundle.
const { NewsletterSubscribeSchema } = await import("@/lib/newsletter/schema");
const parsed = NewsletterSubscribeSchema.safeParse({ email });
if (!parsed.success) {
setErrorMessage(parsed.error.issues[0]?.message ?? "Enter a valid email address");
setStatus("error");
return;
}
setStatus("submitting");
const recaptchaToken = await getRecaptchaToken(NEWSLETTER_RECAPTCHA_ACTION);
if (!recaptchaToken) throw new Error("We couldn't verify your submission. Please try again.");
const response = await fetch("/api/newsletter", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...parsed.data, recaptchaToken }),
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok || !isApiResponse(payload) || !payload.success) {
throw new Error(
isApiResponse(payload) && !payload.success ? payload.message : GENERIC_ERROR_MESSAGE,
);
}
setStatus("success");
setSuccessMessage(payload.message);
setEmail("");
setTimeout(() => setStatus("idle"), 6000);
}
The form also calls preloadRecaptchaScript() in its onFocus handler so the reCAPTCHA script
loads the moment the user touches the input, not on page load.
11. Result pages — NewsletterStatusCard + the three route pages
All three pages (/confirm, /unsubscribe, /resubscribe) share the same shape:
export const dynamic = "force-dynamic"— mutation endpoints, never cachedrobots: { index: false, follow: false }— action pages carry no indexable content- Plain async Server Components — the result is present in the initial HTML, works with JavaScript disabled
(critical since these links are clicked directly from an email client)
// src/app/(site)/confirm/page.tsx
export const dynamic = "force-dynamic";
export default async function ConfirmSubscriptionPage({ searchParams }) {
const { token } = await searchParams;
const parsedToken = NewsletterTokenSchema.safeParse({ token });
const result = parsedToken.success
? await confirmNewsletterSubscription(parsedToken.data.token)
: { success: false as const, message: MISSING_TOKEN_MESSAGE };
return (
<NewsletterStatusCard
status={result.success ? "success" : "error"}
title={result.success ? "You're subscribed!" : "Confirmation failed"}
message={result.message}
>
<Link to="/">Back to Homepage</Link>
</NewsletterStatusCard>
);
}
Resubscribe — pending branch
resubscribe/page.tsx has one extra branch because a successful resubscribe can still be pending
confirmation when double opt-in is on:
const isPending = result.success && result.subscription.status === "pending";
<NewsletterStatusCard
status={result.success ? "success" : "error"}
title={
!result.success ? "Resubscribe failed" :
isPending ? "Almost there!" :
"Welcome back!"
}
message={result.message}
>
12. Security notes specific to this implementation
- The plugin’s own rate limiter does not protect the REST endpoint this app calls. It’s wired into
the WordPress-native AJAX shortcode handler (admin-ajax.php+dropemsu_nonce), not into
POST /subscriptions. reCAPTCHA v3 (verifyRecaptchaToken, min score0.5) is
this project’s actual defense against scripted mass-subscribes on/api/newsletter. - Every WordPress error is filtered before it reaches a response via
isSafeWordPressErrorCode/isSafeWordPressErrorMessage— a response shape that “looks
right” ({code, message}) can still leak a stack trace if something upstream misbehaves. NEWSLETTER_TRUSTED_PROXY_SECRETis optional and additive. Unset: WordPress’s
geolocation sees this server’s IP for every subscriber (harmless, just less accurate country/region data). Set it to
match the WP “Trusted Frontend Secret” andgetTrustedProxyHeaders()forwards the real visitor IP via
X-Forwarded-For+X-Dropndot-Proxy-Secret, which the plugin verifies with
hash_equals().- Admin CRUD routes are never proxied —
GET /subscriptions,
GET/PUT/DELETE /subscriptions/{id}requiremanage_optionson the WordPress side and have
no caller in this codebase.
13. Final Thoughts
Modern digital platforms demand a architecture that balances user experience, security, and maintainability. Implementing a newsletter system using the Dropndot Email Subscriptions and Newsletters plugin alongside Next.js App Router demonstrates how Headless WordPress can be utilized effectively without compromising safety. By keeping WordPress strictly on the server side, validating every incoming request, enforcing Google reCAPTCHA v3, and utilizing double opt-in verification, this implementation guarantees robust bot protection and precise subscriber management. Moreover, using token-based transactional routes and server-only helpers ensures seamless handling of subscription lifecycles even in headless environments. Ultimately, this decoupled pattern turns a simple email subscription feature into a highly scalable, enterprise-grade digital solution for modern web applications.

