Back to Blog
Architecture
Wiring 40+ third-party scripts under OneTrust consent on a UK consumer site
April 30, 20267 min read
ArchitectureCSPOneTrustGDPRPrivacy
The setup
Picture a UK consumer brand site: Next.js + Apollo GraphQL + Umbraco, deployed to Azure Kubernetes. The CMS editors keep adding new tracking pixels. The marketing team wants Contentsquare for session replay, Freespee for call attribution, StoryStream for UGC, plus the usual GTM funnel events. Legal mandates UK GDPR compliance via OneTrust — six cookie categories, granular consent, audit trails. The dev team gets to make all of this work without breaking pageviews.
Eventually this means 40+ third-party origins live in our Content Security Policy (CSP) middleware, and a similar number of scripts wait their turn to load — only after the user clicks "Accept" or whatever finer-grained selection OneTrust shows them.
The conflict
Three concerns pulling in opposite directions:
The trap most teams fall into: ship a script tag inline in
_document.tsx, marketing is happy, legal is unhappy, engineering loses a Friday rolling it back. The pattern this team settled on inverts that flow: scripts don't decide when they run. **OneTrust does.**The pattern: a consent-callback registry
Every external integration follows the same shape:
window.oneTrustConsentFunctions = window.oneTrustConsentFunctions || [];
window.oneTrustConsentFunctions.push({
isPreserved: true,
cookieType: { performance: true },
function: () => {
// load, configure, and call the third-party script here
if (typeof window["__fs_dncs_instance"] !== "undefined") {
window["__fs_dncs_instance"].trackPage();
}
},
});
Each callback declares which cookie category it depends on (
performance, targeting, functional, etc.). When the user grants consent, OneTrust walks the registry and invokes only the matching callbacks. When the user revokes consent, callbacks marked isPreserved: false get unregistered; the rest stay dormant.Why a registry instead of direct script tags?
oneTrustConsentFunctions.push and assert what would have fired.useEffect. No central manifest to maintain.The CSP middleware
Next.js middleware emits the CSP header per request. The rules live in a flat config:
const scriptSrcRules = [
"*.googletagmanager.com",
"*.contentsquare.net",
"*.freespee.com",
"*.storystream.ai",
"*.onetrust.com",
// …
];
A few decisions worth flagging:
'unsafe-inline' and 'unsafe-eval' are present in script-src.** Removing them was attempted and rolled back: StoryStream and Contentsquare both inject inline scripts and use eval-equivalents, and pulling the directives breaks both. The trade-off the team accepted: weaker XSS posture in exchange for vendor compatibility, with the compensating controls living elsewhere — no user-supplied HTML rendering, strict input sanitisation, CSRF protections.default-src list is the ground truth.** Every other directive (script, connect, frame, img, font, media) inherits from it conceptually but is enumerated explicitly to avoid accidental allow-list bleed.process.env.ENV_STAGE and composes accordingly — same code, different rules.The trade-offs
**Performance.** Forty origins isn't free. Even with consent gating, each opt-in triggers a small avalanche of script loads. TBT (Total Blocking Time) regressions show up in the perf budget; new pixels go through a triage that pushes back on duplicates. Some pixels eventually land on a "no, that's a duplicate of pixel X" list.
**Debugging.** When a script doesn't load, the failure mode is almost always one of: (a) consent not granted for that category, (b) origin missing from CSP, (c) script self-blocks because it hasn't seen its setup state yet. There's a small "consent debug" overlay engineers can enable via a query param — saves a lot of time per misfire.
**Editor surface area.** The CMS lets editors drop a "Tracking Snippet" content block. The temptation is to delete it (engineers control tracking, end of story). Reality: marketing uses it for one-off campaign pixels. The compromise the team settled on keeps the block but makes it consent-gated — it requires the editor to choose a cookie category, and the rendered script registers a callback automatically. Editors stay empowered; legal stays happy.
Where the next iterations might go
**Trusted Types.**
'unsafe-inline' is a legacy compromise. If StoryStream and Contentsquare ever ship CSP-compliant builds (the trend in 2026 looks promising), pulling both directives becomes feasible.**First-party analytics where possible.** Reverse-proxying GA4 through the same domain dodges some adblockers and shrinks the CSP origin list. There's a real perf and reliability win in there, weighed against the dev cost.
**Schema-first registry.** The current callback shape is a convention, not a type. A small Zod schema for
OneTrustCallback with an exhaustive cookieType union would catch typos at build time and make it impossible to register a callback that depends on a category that doesn't exist.The takeaway from working in this kind of system: a CSP middleware is one tool. A consent registry is another. Each on its own is fragile; together they cover the awkward middle ground where regulation, marketing, and engineering all need to keep moving.