devto 2026-07-07 원문 보기 ↗
A client wanted a newsletter popup on their WooCommerce/Divi site to hand out a lead-magnet PDF and grow their Mailchimp audience. The obvious paths — a popup plugin, or Mailchimp's own hosted popup — both lose on the things I actually care about: page weight, brand control, and not loading someone else's JavaScript on every pageview.
So I built it as a custom component: a modal that posts to my own WordPress REST endpoint, which talks to Mailchimp server-side. No plugin, no Mailchimp front-end script, no reCAPTCHA. Here's the whole thing, including a CSS bug that cost me ten confusing minutes.
Three reasons I don't reach for either on a performance-sensitive site:
Three moving parts:
namespace/v1/subscribe) registered in the theme. It receives the email, validates it, and does the Mailchimp call server-side so the API key never touches the browser.wp_footer, gated by page conditionals, with the API key handed to the front end as nothing — only the endpoint URL and a nonce.mu-plugin, no external assets beyond one CSS and one JS file.The key architectural decision: the browser never sees the API key. The front end posts an email to your endpoint; your endpoint authenticates to Mailchimp. This is the whole reason not to use Mailchimp's client-side embed.
Register the route and read your credentials from wp-config.php constants (never hard-code them in theme files that live in version control or the DB):
add_action( 'rest_api_init', function () {
register_rest_route( 'namespace/v1', '/subscribe', array(
'methods' => 'POST',
'permission_callback' => '__return_true', // public; guarded by nonce + honeypot
'args' => array(
'email' => array(
'required' => true,
'sanitize_callback' => 'sanitize_email',
'validate_callback' => 'is_email',
),
'hp' => array( 'sanitize_callback' => 'sanitize_text_field' ),
),
'callback' => 'np_subscribe',
) );
} );
The Mailchimp v3 subscribe is an upsert: PUT /lists/{list_id}/members/{subscriber_hash}, where the hash is the MD5 of the lowercased email. Using PUT (not POST) means re-submits don't error — they update the existing member instead of throwing "already subscribed."
function np_subscribe( WP_REST_Request $req ) {
// Honeypot: any value means bot. Fake a success so it doesn't retry.
if ( ! empty( $req->get_param( 'hp' ) ) ) {
return new WP_REST_Response( array( 'ok' => true ), 200 );
}
if ( ! defined( 'MC_API_KEY' ) || ! defined( 'MC_SERVER' ) || ! defined( 'MC_LIST_ID' ) ) {
return new WP_REST_Response(
array( 'ok' => false, 'message' => 'Newsletter is not configured yet.' ),
503
);
}
$email = sanitize_email( $req->get_param( 'email' ) );
$hash = md5( strtolower( $email ) );
$auth = 'Basic ' . base64_encode( 'user:' . MC_API_KEY );
$status = 'subscribed'; // or 'pending' for double opt-in — see below
$res = wp_remote_request(
"https://" . MC_SERVER . ".api.mailchimp.com/3.0/lists/" . MC_LIST_ID . "/members/{$hash}",
array(
'method' => 'PUT',
'timeout' => 15,
'headers' => array(
'Authorization' => $auth,
'Content-Type' => 'application/json',
),
'body' => wp_json_encode( array(
'email_address' => $email,
'status_if_new' => $status,
) ),
)
);
if ( is_wp_error( $res ) ) {
return new WP_REST_Response( array( 'ok' => false, 'message' => 'Try again shortly.' ), 502 );
}
$code = wp_remote_retrieve_response_code( $res );
if ( $code >= 400 ) {
// Log the real Mailchimp reason server-side; keep the user message clean.
error_log( 'Mailchimp ' . $code . ': ' . wp_remote_retrieve_body( $res ) );
return new WP_REST_Response( array( 'ok' => false, 'message' => 'Couldn\'t add you right now.' ), 502 );
}
// Tag for segmentation — fire-and-forget, don't block success on it.
wp_remote_post(
"https://" . MC_SERVER . ".api.mailchimp.com/3.0/lists/" . MC_LIST_ID . "/members/{$hash}/tags",
array(
'timeout' => 10,
'headers' => array( 'Authorization' => $auth, 'Content-Type' => 'application/json' ),
'body' => wp_json_encode( array(
'tags' => array( array( 'name' => 'homepage-popup', 'status' => 'active' ) ),
) ),
)
);
return new WP_REST_Response( array( 'ok' => true ), 200 );
}
Two details worth calling out:
base64_encode('user:' . $key) — Mailchimp uses HTTP Basic auth where the username is literally any string and the password is the API key.us8, us21, etc.) is the suffix on your API key after the dash, and it's part of the API host. Store it as its own constant so the base URL is composed, not hard-coded.
The tag call is deliberately fire-and-forget. If tagging fails, the subscribe already succeeded — I don't want to fail the user's request over a segmentation nicety. Log it and move on.This is the detail that bites people. Mailchimp audiences are configured for single or double opt-in, and your status_if_new has to match:
status: pending. Mailchimp emails a confirmation link; the member isn't subscribed until they click. If you send pending on a single-opt-in audience, you strand contacts in limbo — no confirmation email is sent, so they sit pending forever.Single opt-in → send status: subscribed. They're live immediately.
But it's not just the API value — it changes your success copy. My first success message said "check your inbox to confirm." On a single-opt-in audience, no confirmation email exists, so that copy is a lie. Match the message to the mode:
Double: "Almost there — confirm via the email we just sent."
Single: "You're on the list. Grab your guide below."
Check the audience setting before you write a single line of success-state copy. Single opt-in trades a confirmation step for higher capture at the cost of more typo/bot noise in your list — which is exactly why the next section matters.
For a plain email capture, reCAPTCHA is the wrong tool. It adds a third-party script (CWV hit), a privacy footprint, and occasional user friction — all to guard a single input. A honeypot catches the bots that matter here with zero weight and zero UX cost.
Add a field that's invisible to humans and irresistible to naive bots:
<input class="np-hp" type="text" tabindex="-1" autocomplete="off"
aria-hidden="true" data-np-hp>
.np-hp {
position: absolute !important;
left: -9999px !important;
width: 1px; height: 1px;
opacity: 0;
}
If it comes back filled, it's a bot — return a fake 200 so it doesn't retry against a real error path. Real users never see or touch it. tabindex="-1" and aria-hidden keep it out of keyboard and screen-reader flow.
This won't stop a determined targeted attack, but for newsletter spam it filters the overwhelming majority with none of reCAPTCHA's costs. If abuse ever escalates, you add rate-limiting server-side before you reach for a CAPTCHA.
The modal renders once in wp_footer, gated to the surfaces that should show it and never to logged-in users:
function np_should_render() {
if ( is_user_logged_in() ) return false; // members don't need the lead magnet
return is_front_page() || is_home() || is_singular( 'post' );
}
The JS fires on whichever trigger hits first: exit-intent, scroll depth, or a time fallback. Exit-intent converts best and annoys least; the timer is just a ceiling.
var timer = setTimeout(open, cfg.delayMs); // e.g. 15000
document.addEventListener('mouseout', function (e) { // exit-intent
if (e.clientY <= 0) open();
});
window.addEventListener('scroll', function () { // scroll depth
var h = document.documentElement;
if ((h.scrollTop) / (h.scrollHeight - h.clientHeight) * 100 >= cfg.scrollPct) open();
}, { passive: true });
The frequency cap uses localStorage — once someone sees or submits, don't show it again for N days:
var CAP_MS = cfg.capDays * 864e5;
function recentlySeen() {
try {
var t = parseInt(localStorage.getItem('np_seen'), 10);
return t && (Date.now() - t) < CAP_MS;
} catch (e) { return false; }
}
Wrap storage access in try/catch — private-mode and storage-disabled browsers throw, and a newsletter popup should never take down the page.
Google penalizes intrusive interstitials on mobile that block content on load. A full-screen modal that fires immediately is exactly what they target. Two mitigations:
@media (max-width: 600px) {
.np { align-items: flex-end; padding: 0; }
.np__card {
max-width: 100%;
max-height: 92vh; /* never taller than the viewport */
overflow-y: auto; /* scroll instead of clipping the CTA */
border-radius: 12px 12px 0 0;
}
}
I also dropped the guide-cover image entirely on mobile. In a bottom sheet it fought for vertical space it didn't earn, and a text-only sheet converts fine. Desktop keeps the two-column layout with the cover.
[hidden] vs display
Here's the one that cost me. On submit, the controller hides the form view and shows the success view:
root.querySelector('[data-np-view="form"]').hidden = true;
root.querySelector('[data-np-view="success"]').hidden = false;
After wiring everything up, submitting showed the success message and the form at the same time, with the button stuck on "Sending…". The API had returned 200 — the integration was fine. The bug was pure CSS.
The form view was a two-column flex container:
.np__body--split { display: flex; }
The hidden attribute works by applying display: none — but at the user-agent stylesheet's specificity, which is effectively zero. My .np__body--split { display: flex } rule outranks it. So setting .hidden = true added the attribute, but display: flex kept winning and the element stayed visible.
The fix is a one-liner that lets [hidden] beat the flex rule:
.np__body--split[hidden] { display: none; }
The lesson: any time you set an explicit display on an element you also toggle with the hidden attribute (or a hidden class), you have to re-assert display: none at matching-or-higher specificity. This is a silent, common trap with flex/grid components that get shown and hidden in JS.
A few things specific to shipping this on managed WordPress hosting:
functions.php include line, wp-config.php constants). Never push the staging DB for a change like this; you'll clobber live orders and comments.wp-config.php separately. They don't ride along on a file push, and that's correct — you may point staging at a test audience.Purge the cache. The modal markup is in wp_footer, so a full-page cache (Varnish/Breeze/Cloudflare) will serve a stale page without it. Purge after deploy and test in a private window.
Keep the API key server-side. A custom REST endpoint is a few lines more than the hosted embed and removes an entire class of exposure and page-weight problems.
Match status_if_new to the audience's opt-in mode — and match your success copy to it too.
Honeypot over reCAPTCHA for simple captures. Add rate-limiting before you ever add a CAPTCHA.
[hidden] loses to any explicit display. Re-assert display: none on the hidden state of flex/grid components.
Design mobile as a bottom sheet to stay clear of Google's interstitial penalty.
The whole thing is one PHP file, one CSS file, and one JS file in the child theme. No plugin, no third-party script, full brand control — and the data lands in the same Mailchimp audience it would have anyway.