Building a custom cookie banner for Shopify themes
Last verified: September 27, 2026 against Shopify's Customer Privacy API documentation and Alpine.js v3.
I recently built a custom cookie banner for this site's Shopify theme. I wanted it to match the rest of the design, but I didn't want to write my own consent system.
I didn't need to. Shopify's Customer Privacy API handles the hard parts: whether the store shows a banner in the visitor's region, what they have already chosen, and how that choice applies to Shopify-managed surfaces such as pixels and checkout. The theme only needs to provide the interface.
Combined with Alpine.js, the whole component is small. This is how I put it together.
The split: Shopify decides, the theme displays
Before writing any code, it helps to be clear about who does what:
- Shopify knows the merchant's privacy settings, the visitor's region, and their saved consent.
- The theme renders the banner, collects a click, and passes the answer to Shopify.
- Alpine keeps track of the few bits of UI state in between.
The theme never decides which regions need a banner, and it never stores consent itself. That keeps the component simple and avoids a second, conflicting source of truth.
One setup step first: switch off Shopify's own banner in Settings → Customer privacy → Cookie banner, or visitors will see both. Keep configuring your privacy regions there, because the API uses those settings.
Step 1: Load the API
The Customer Privacy API is loaded on demand with Shopify.loadFeatures:
window.Shopify.loadFeatures(
[{ name: 'consent-tracking-api', version: '0.1' }],
error => {
if (error) return;
const privacy = window.Shopify.customerPrivacy;
},
);
Once the callback runs without an error, everything lives on window.Shopify.customerPrivacy.
If it fails to load, show nothing. A banner that cannot save an answer is worse than no banner.
Step 2: The three methods you need
The API has plenty of methods, but a banner mostly needs three.
Should the banner appear?
privacy.shouldShowBanner(); // true or false
This returns true when the visitor is in a region where the store is configured to show a cookie banner. No country lists or cookie checks needed in the theme.
One catch I hit on my own site: it doesn't tell you whether the visitor has already answered. It kept returning true after a choice was saved, so the banner came back on every refresh. Pair it with the next method.
What has the visitor chosen?
privacy.currentVisitorConsent();
// { analytics: 'yes', marketing: 'no', preferences: '', sale_of_data: '' }
'yes' means granted, 'no' means denied, and an empty string means they haven't answered.
Combined, the visibility check is short:
const current = privacy.currentVisitorConsent();
const answered = ['analytics', 'marketing', 'preferences'].every(
signal => current[signal] !== '',
);
const showBanner = privacy.shouldShowBanner() && !answered;
Save their choice:
privacy.setTrackingConsent(
{ analytics: true, marketing: false, preferences: true },
() => console.log('Saved'),
);
That's the core of the integration. Everything else is interface.
Step 3: Wrap it in an Alpine component
I registered the banner as a reusable component with Alpine.data(). It keeps the Shopify API in a local variable and exposes only what the markup needs:
export default function cookieConsent() {
let privacy = null;
return {
visible: false,
detailed: false,
choices: { analytics: false, marketing: false, preferences: false },
init() {
window.Shopify?.loadFeatures?.(
[{ name: 'consent-tracking-api', version: '0.1' }],
error => {
if (error) return;
privacy = window.Shopify.customerPrivacy;
const current = privacy.currentVisitorConsent();
const answered = current.analytics !== '' && current.marketing !== '' && current.preferences !== '';
this.visible = privacy.shouldShowBanner() && !answered;
},
);
},
acceptAll() {
this.save({ analytics: true, marketing: true, preferences: true });
},
declineAll() {
this.save({ analytics: false, marketing: false, preferences: false });
},
saveChoices() {
this.save({ ...this.choices });
},
save(consent) {
privacy?.setTrackingConsent(consent, error => {
if (error) return;
this.visible = false;
});
},
};
}
Then register it once, before Alpine.start():
import cookieConsent from './cookie-consent.js';
Alpine.data('cookieConsent', cookieConsent);
Notice that the banner only closes inside the setTrackingConsent callback, and only when there's no error. Shopify calls the callback on failure too, passing an object with an error message, so the banner stays put and the visitor can try again.
The checkboxes also start unticked. A box the visitor never touched isn't consent.
Step 4: The markup
Liquid renders the text. Alpine shows, hides, and binds:
<div x-data="cookieConsent" x-show="visible" x-cloak x-transition.opacity>
<p>{{ 'cookies.message' | t }}</p>
<div x-show="detailed">
<label>
<input type="checkbox" x-model="choices.analytics">
{{ 'cookies.analytics' | t }}
</label>
<label>
<input type="checkbox" x-model="choices.marketing">
{{ 'cookies.marketing' | t }}
</label>
<label>
<input type="checkbox" x-model="choices.preferences">
{{ 'cookies.preferences' | t }}
</label>
<button type="button" @click="saveChoices()">
{{ 'cookies.save' | t }}
</button>
</div>
<div x-show="!detailed">
<button type="button" @click="acceptAll()">{{ 'cookies.accept' | t }}</button>
<button type="button" @click="declineAll()">{{ 'cookies.decline' | t }}</button>
<button type="button" @click="detailed = true">{{ 'cookies.manage' | t }}</button>
</div>
</div>
x-model keeps each checkbox in sync with choices, so saveChoices() doesn't need to read the DOM at all.
Two things worth doing here:
- Give Accept and Decline the same size and style. Making decline harder to find undermines the whole point of asking.
- Add
[x-cloak] { display: none !important; }to your CSS so the banner doesn't flash before Alpine starts.
Step 5: Let visitors change their mind
A visitor should be able to reopen their preferences at any time. I added a small listener to the component:
init() {
window.addEventListener('cookie-consent:open', () => this.open());
// ...load the API as before
},
open() {
if (!privacy) return;
const current = privacy.currentVisitorConsent();
this.choices = {
analytics: current.analytics === 'yes',
marketing: current.marketing === 'yes',
preferences: current.preferences === 'yes',
};
this.detailed = true;
this.visible = true;
},
And a button in the footer that dispatches the event:
<button
type="button"
@click="window.dispatchEvent(new Event('cookie-consent:open'))"
>
{{ 'cookies.settings' | t }}
</button>
Using an event means the footer doesn't need to know anything about the banner. It just announces that the visitor wants to see their settings.
The reopened banner starts on the checkbox view, pre-filled with whatever Shopify currently has saved.
Step 6: Check permissions before loading your own scripts
Shopify applies consent to its own pixels and checkout. Any third-party script you add to the theme is still your responsibility.
The API has a permission check for each purpose:
privacy.analyticsProcessingAllowed();
privacy.marketingAllowed();
privacy.preferencesProcessingAllowed();
These combine the visitor's choice with the merchant's settings and the visitor's location, so they are the right thing to check before running code. For example:
let analyticsLoaded = false;
const loadAnalytics = () => {
if (analyticsLoaded) return;
analyticsLoaded = true;
// Add the analytics script here.
};
if (privacy.analyticsProcessingAllowed()) {
loadAnalytics();
}
document.addEventListener('visitorConsentCollected', event => {
if (event.detail.analyticsAllowed) loadAnalytics();
});
The event only fires when consent changes, so check once on load as well. The guard stops the script loading twice if both paths run.
What I deliberately left out
A few things belong outside the banner:
-
Data sale opt-out. The API supports a
sale_of_datasignal, but Shopify recommends handling it as a separate flow that the customer starts. The banner only records analytics, marketing, and preferences. - Reading Shopify's cookies. Shopify warns that cookie internals can change without notice. Always go through the API.
-
My own consent storage. Shopify already stores the decision. A second copy in
localStoragecan drift out of sync and hide the banner when Shopify still needs an answer.
Why this approach works well
The finished component is about 160 lines of JavaScript, and much of that is small guards and comments. The browser-side logic is simple because Shopify already handles regional settings, consent storage, and enforcement on its own surfaces.
That's what I like about building with this API. You get complete control over the design, and Alpine keeps the state readable, without writing any privacy logic yourself.
If you're building a Shopify theme with a custom banner, start with the API docs: https://shopify.dev/docs/api/customer-privacy
This is a technical guide rather than legal advice. Check the privacy requirements that apply to your store with a qualified adviser.