Here’s the playbook I use in production:
1) Treat it as a black box (isolate it)
- Dedicated route or subdomain just for the PSE element (e.g.,
/searchorsearch.yourdomain.com). - No shared global CSS/JS there; minimal shell only.
- If you must keep it on the same origin, mount/unmount it on a standalone page so its globals can’t step on the rest of your app.
2) Load it lazily, explicitly, and only inside its container
<div id="cse" role="search"></div>
<script nonce="{{nonce}}">
// don’t let it auto-scan the whole document
window.__gcse = { parsetags: 'explicit' };
const loadPSE = () => {
const s = document.createElement('script');
s.async = true; s.defer = true;
s.src = 'https://cse.google.com/cse.js?cx={{YOUR_CX_ID}}';
s.onload = () => google.search.cse.element.render({ div: 'cse', tag: 'search' });
document.head.appendChild(s);
};
// lazy-load when visible
new IntersectionObserver(([e], obs) => {
if (e.isIntersecting) { loadPSE(); obs.disconnect(); }
}).observe(document.getElementById('cse'));
</script>
This keeps their parser focused on #cse, defers network work, and avoids them rummaging through your whole DOM.
3) Lock it down with policy, not hope
Add headers on that page (or subdomain) so third-party code stays in its lane:
- CSP (tight, allowlisted):
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{{nonce}}' https://cse.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: https://www.google.com https://www.gstatic.com; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; - Permissions-Policy: disable what it doesn’t need (
geolocation=(), camera=(), microphone=()…). - Referrer-Policy:
strict-origin-when-cross-origin. - HSTS / nosniff: standard hardening.
Note: SRI won’t work reliably on
cse.js(dynamic URL). CSP + domain allowlist + nonces is the right containment.
4) Keep the rest of your app symmetric
- Single page shell owns all meta, analytics, and resource hints. PSE lives outside that shell.
- Contracts only: if you need to pass data in/out, do it via a tiny adapter (URL params or a message bus), never via shared globals.
- Budget guardrails: perf budget in CI that excludes this route from your main app’s budgets, so their 3P weight doesn’t excuse regressions elsewhere.
- Tripwire test: a tiny jest/e2e check that records a few intrinsic descriptors before/after mounting and fails if globals are mutated.
5) If you outgrow PSE
Define a clean API (/search?q=…&cursor=…) and render results with your own SSR/SPA component library. That gives you real symmetry: typed request/response, observability, and deterministic builds—no opaque frontend blob.
Bottom line: Don’t wrestle their code into order—quarantine it and control the seam. Your backend and core frontend stay perfectly symmetrical; the third-party widget becomes a boxed edge with clear contracts and zero surprise side-effects.