How to Build a Self-Hosted Cookie Banner (No External CMP) Using localStorage + Conditional Tracking Injection
Cookie banners are one of those modern web chores: boring, important, and legally spicy. Many sites outsource consent management to external platforms (CMPs), but you can absolutely build a self-hosted cookie banner that keeps the essential functionality: store user choices, block tracking by default, and load analytics scripts only when the user opts in.
This guide shows a practical approach using vanilla JavaScript and localStorage. We'll store consent as a small JSON object, then inject tracking code only if consent is enabled (and skip injection if it's disabled).
Note: This is a technical implementation guide, not legal advice. Laws and regulator expectations vary by country and can change over time. Still, most guidance converges on a few core ideas: clear information, opt-in for non-essential trackers, and the ability to withdraw consent easily.
Why Build Your Own Cookie Banner?
Pros (Control, Performance, No Vendor Lock-in)
- Performance: No external scripts just to show a banner
- Control: You decide the UX and the categories
- No vendor lock-in: Fewer third parties touching your site
- Customization: Match your brand perfectly
Cons (Maintenance, Legal Accuracy, Testing Burden)
- You own maintenance: Browsers evolve, tracking stacks evolve, regulators evolve
- You must test thoroughly: Especially "no tracking before consent"
- You must document: Users should be clearly informed about what you do
- Legal responsibility: You're responsible for compliance
What "Consent" Usually Needs to Look Like
Most cookie rules treat "cookies and similar technologies" broadly (not just cookies: localStorage, pixels, SDKs, etc.). The recurring expectations are:
Essential vs Analytics vs Marketing
- Essential: Required for the website to function (session, authentication, preferences)
- Analytics: Track user behavior to improve the site (Google Analytics, Matomo)
- Marketing: Track users across sites for advertising (Facebook Pixel, Google Ads)
Withdraw / Change Consent Must Be Easy
Regulations emphasize that users must be able to withdraw consent as easily as they gave it. This means:
- Persistent "Cookie Settings" link accessible from any page
- Clear toggles for each category
- Immediate application of changes
Reject Should Be as Accessible as Accept
A common enforcement theme in Europe: refusing should not be "hidden" behind extra friction. We'll reflect this by offering:
- Accept all - One click to enable everything
- Reject all - One click to disable non-essential
- Manage preferences - Granular control
Architecture: How the Solution Works
Data Model Stored in localStorage
We store consent preferences as a JSON object in localStorage:
{
"version": "1.0",
"timestamp": "2026-01-14T10:15:00.000Z",
"preferences": {
"essential": true,
"analytics": false,
"marketing": false
}
}
Default Behavior: Block Tracking Until Consent
No consent stored = do not load analytics/marketing scripts. This is the most important rule for GDPR compliance.
Conditional Script Injection
Scripts are loaded dynamically based on consent:
- If
analytics === true, inject Google Analytics - If
marketing === true, inject Meta Pixel - If
false, do nothing (and clean up any existing cookies)
"Manage Preferences" UI + Persistent "Cookie Settings" Link
Users can access cookie settings at any time via:
- Fixed widget button (bottom-left corner)
- Link in the Cookie Policy page
- Modal with detailed category descriptions
Step-by-Step Implementation (HTML + CSS + JS)
Step 1 — Add the Banner Markup
Create a file sections/cookie-banner.html:
<!-- Cookie Consent Banner -->
<div id="cookie-banner" class="cookie-banner" style="display: none;">
<div class="cookie-banner-content">
<!-- Titolo e Reject All in alto a sinistra -->
<div class="cookie-banner-header">
<i class="ph ph-cookie"></i>
<h3>Your Privacy Matters</h3>
<button id="cookie-reject-btn" class="cookie-banner-reject-link">
Reject All
</button>
</div>
<!-- Bottoni in alto a destra -->
<div class="cookie-banner-actions">
<button id="cookie-settings-btn" class="cookie-btn cookie-btn-secondary">
<i class="ph ph-gear"></i> Manage Preferences
</button>
<button id="cookie-accept-btn" class="cookie-btn cookie-btn-primary">
Accept All
</button>
</div>
<!-- Descrizione in basso a sinistra -->
<div class="cookie-banner-body">
<p>We use cookies and similar technologies to enhance your browsing experience,
analyze website traffic, and deliver personalized advertising. By clicking "Accept All",
you consent to our use of cookies.</p>
</div>
<!-- Link policy in basso a destra -->
<p class="cookie-banner-links">
<a href="/privacy-policy.html">Privacy Policy</a> •
<a href="/cookie-policy.html">Cookie Policy</a>
</p>
</div>
</div>
<!-- Cookie Manage Widget - Fixed bottom left -->
<button id="cookie-manage-widget" class="cookie-manage-widget"
aria-label="Manage Cookie Preferences" title="Cookie Settings">
<i class="ph ph-cookie"></i>
</button>
Step 2 — Add Styling
The banner uses a modern grid layout with responsive design. Key features:
- Fixed position at bottom of viewport
- Dark theme with purple accent colors
- Dynamic padding based on viewport width
- Smooth slide-up animation on appearance
Create css/cookie-banner.css with the complete styling (see full implementation).
Step 3 — Implement Consent Storage in localStorage
The CookieConsent class manages all consent operations:
class CookieConsent {
constructor() {
this.CONSENT_KEY = 'cookie_consent';
this.CONSENT_VERSION = '1.0';
this.consent = this.loadConsent();
this.init();
}
loadConsent() {
try {
const stored = localStorage.getItem(this.CONSENT_KEY);
return stored ? JSON.parse(stored) : null;
} catch (e) {
console.error('Error loading cookie consent:', e);
return null;
}
}
saveConsent(preferences) {
const consent = {
version: this.CONSENT_VERSION,
timestamp: new Date().toISOString(),
preferences: preferences
};
try {
localStorage.setItem(this.CONSENT_KEY, JSON.stringify(consent));
this.consent = consent;
} catch (e) {
console.error('Error saving cookie consent:', e);
}
}
}
Step 4 — Inject Analytics Scripts Only If Enabled
This is the critical part for GDPR compliance. Scripts are loaded only after user consent:
loadGoogleAnalytics() {
// Check if already loaded
if (window.gtag) {
console.log('Google Analytics already loaded');
return;
}
// Load Google Analytics
const script = document.createElement('script');
script.async = true;
script.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX';
document.head.appendChild(script);
script.onload = () => {
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
window.gtag = gtag;
gtag('js', new Date());
gtag('config', 'G-XXXXXXX', {
'anonymize_ip': true,
'cookie_flags': 'SameSite=None;Secure'
});
console.log('Google Analytics loaded with consent');
};
}
loadMetaPixel() {
// Check if already loaded
if (window.fbq) {
console.log('Meta Pixel already loaded');
return;
}
// Load Meta Pixel
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
window.fbq('init', 'XXXXXXXXX');
window.fbq('track', 'PageView');
console.log('Meta Pixel loaded with consent');
}
Step 5 — Add Settings Button + Allow Changes Later
Users must be able to change their preferences at any time. We provide two access points:
- Fixed widget: A floating cookie icon in the bottom-left corner
- Footer link: "Cookie Settings" link on the Cookie Policy page
// Cookie manage widget (fixed button)
const manageWidget = document.getElementById('cookie-manage-widget');
if (manageWidget) {
manageWidget.addEventListener('click', () => {
this.showModal();
});
}
Step 6 — Optional: Best-Effort Cookie Cleanup on Withdrawal
When users revoke consent, we attempt to clean up tracking cookies:
disableGoogleAnalytics() {
// Set opt-out flag
window['ga-disable-G-XXXXXXX'] = true;
// Delete GA cookies
this.deleteCookie('_ga');
this.deleteCookie('_gid');
this.deleteCookie('_gat');
this.deleteCookie('_ga_XXXXXXX');
console.log('Google Analytics disabled');
}
deleteCookie(name) {
// Delete cookie for current domain
document.cookie = name + '=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
// Try to delete for parent domain as well
const domain = window.location.hostname;
document.cookie = name + '=; Path=/; Domain=' + domain + '; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
// Try with dot prefix
document.cookie = name + '=; Path=/; Domain=.' + domain + '; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
}
Important: After changing preferences, the page automatically reloads to ensure all tracking scripts are properly loaded or removed.
Testing Checklist (DevTools)
Verify localStorage
- Open DevTools → Application → Local Storage
- Look for the
cookie_consentkey - Verify the JSON structure matches expectations
Verify Scripts Are Not Loaded Pre-Consent
- Open site in incognito mode
- Before clicking anything, check Network tab
- Verify NO requests to
googletagmanager.comorfacebook.net - Check Elements tab: no analytics
<script>tags
Verify Toggles and Persistence
- Click "Accept All" → Reload page
- Banner should NOT appear (consent saved)
- Analytics requests should be present in Network tab
- Click cookie widget → Disable analytics → Save
- After reload: NO analytics requests
Verify "Withdrawal Is as Easy as Consent"
- Ensure cookie widget is always visible
- Click widget → Modal opens immediately
- Toggle any preference → Save
- Changes applied after reload
Common Mistakes (And How to Avoid Them)
FAQ
In many jurisdictions, yes—analytics are usually not considered "strictly necessary" unless they are completely anonymous and don't track users across sites.
Technically it's still "storing/accessing information on a device." It's commonly used to remember consent choices, but confirm your compliance approach and document it clearly in your privacy policy.
Often recommended as a best practice because enforcement actions frequently focus on making refusal easy and not more difficult than acceptance. The "Reject all" button should be as prominent as "Accept all".
Increment the CONSENT_VERSION in your code. Users with old consent versions will be re-prompted with the banner to make a new choice.
Yes. While you can disable tracking via opt-out flags, the cleanest approach is to reload the page so scripts are either loaded or not loaded based on the new preferences.
Conclusion + Next Steps
A self-hosted cookie banner can be clean, fast, and fully functional—if you follow one core rule: no tracking loads until the user opts in. localStorage gives you a simple way to persist choices, and conditional script injection keeps your tracking behavior honest.
Next Steps:
- ✅ Implement the banner on your site
- ✅ Update your Privacy Policy to document your tracking
- ✅ Update your Cookie Policy with detailed cookie tables
- ✅ Test thoroughly in multiple browsers
- ✅ Consider adding more granular categories if needed
- ✅ Set up monitoring to ensure scripts don't accidentally load pre-consent
Want help implementing a custom cookie consent solution for your business? Get in touch and let's build something compliant and user-friendly together.