Install Apex on a headless Nuxt storefront
Add Apex to a Nuxt 3 Shopify storefront, configure OneTrust consent, handle SPA navigation, and preserve cart attribution.
This guide installs Apex on a Nuxt 3 storefront backed by Shopify. Add the loader once through your central Nuxt plugin, then configure the SPA and consent presets in Apex.
Install the loader
Replace <shopId> with the shop ID shown in Apex. Choose one loader variant; do not install both.
Direct load (recommended)
Use the direct loader when consent is configured in the Apex dashboard. The SDK then loads before consent, and Apex's consent integration controls storage and tracking.
Create plugins/apex.ts — a universal plugin, not .client.ts. During server-side rendering, useHead writes the script tag into the served HTML, so the browser discovers the SDK with the initial document instead of after the client bundle boots. (A nuxt.config.ts app.head.script entry achieves the same; use the plugin form when the shop ID comes from runtime config.)
export default defineNuxtPlugin(() => {
useHead({
script: [
{
key: "apex-loader",
src: "https://events.drip-apex.com/s/<shopId>.js",
async: true,
},
],
});
});The async loader does not block rendering, and because the tag is server-rendered it starts downloading with the initial document. It is still best-effort for the first paint: an async script can finish after the browser begins painting. If the shop requires the strongest pre-paint guarantee, use the synchronous loader pattern generated by Apex instead.
CMP-gated load (OneTrust)
If your storefront requires OneTrust to activate the Apex tag itself, emit the loader as an inert Targeting-cookie script (same universal-plugin file):
export default defineNuxtPlugin(() => {
useHead({
script: [
{
key: "apex-loader",
src: "https://events.drip-apex.com/s/<shopId>.js",
type: "text/plain",
class: "optanon-category-C0004",
},
],
});
});OneTrust changes this tag into an executable script after the visitor grants the C0004 category. This stricter gate means the SDK cannot run before consent, so the pre-paint anti-flicker path cannot protect the first paint. When flicker matters, prefer direct loading plus Apex's own consent gating.
Apply the Nuxt SPA preset
In the Apex dashboard, open the shop's Installation settings and set two controls:
- Apply timing (
spa.applyTiming): select After hydration. - DOM changes (
spa.domChangeTrigger): enable it.
After hydration waits for the Nuxt application to hydrate before mutations are applied — the SDK recognizes a Nuxt page through window.__NUXT__ and the #__nuxt root and holds mutations until hydration completes. DOM changes re-evaluates eligible mutations after Nuxt swaps or updates the view; enable it explicitly, because it is what keeps mutations applied across client-side updates on Nuxt. These two controls are the complete SPA setup for Nuxt.
Configure OneTrust consent
In the Apex dashboard, choose the OneTrust consent integration. Its default category is C0004, the standard OneTrust Targeting Cookies group. Keep that value when Apex belongs in the same category as your testing and analytics tools, or replace it with the category ID your consent policy assigns to Apex.
The SDK reads the category list from window.OnetrustActiveGroups. It starts consent-controlled storage and tracking only when the configured group is present. Test both a denied visit and a newly granted visit after changing the category.
If you selected the CMP-gated loader above, OneTrust controls when the SDK code loads as well as what the in-dashboard integration permits. With the recommended direct loader, the SDK is available for pre-paint work while the integration still gates consent-controlled behavior.
Handle client-side navigation
Nuxt's default history navigation works with Apex; install the plugin once rather than once per page. On client-side route changes, the SDK observes the URL and re-evaluates after the new view is rendered. With domChangeTrigger enabled, later component and DOM updates also cause eligible mutations to be re-applied.
See SPA and router modes for router overrides, manual activation, and debounce settings.
Read feature flags with Vue composables
Install the SDK package release containing the Vue bindings, then add DripPlugin to your existing universal loader plugin. Keep the hosted loader configured above and omit SDK init config when installing the plugin. Declare the private ingest token key in nuxt.config so the NUXT_APEX_INGEST_TOKEN environment variable fills it on the server only. Its value must be exactly the Edge Worker's APEX_INGEST_TOKEN: the middleware verifies the worker's HMAC with it, and any other value rejects every forwarded envelope as invalid_signature.
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
apexIngestToken: "", // server-only; NUXT_APEX_INGEST_TOKEN = the Edge Worker's APEX_INGEST_TOKEN
},
});import { DripPlugin } from "@drip-apex/sdk/vue";
export default defineNuxtPlugin((nuxtApp) => {
// Keep the useHead loader configuration from the installation section.
const event = import.meta.server ? useRequestEvent() : undefined;
nuxtApp.vueApp.use(DripPlugin, undefined, {
serverAssignments: event?.context.apexAssignments,
nuxt: { useRequestHeaders, useCookie, useState },
});
});<script setup lang="ts">
import { useFeatureFlag, useFeatureFlagValue } from "@drip-apex/sdk/vue";
const enabled = useFeatureFlag("checkout-progress");
const copy = useFeatureFlagValue("checkout-copy", "Continue to checkout");
</script>
<template><button :data-progress="enabled">{{ copy }}</button></template>Render variants on the server
With the customer-deployed Edge Worker 1.1.16 or later, the worker forwards the visitor id and a signed assignment envelope to the origin so the origin can render variants without a hydration flicker. The worker strips incoming client assignment headers and signs its own exact JSON payload with hex HMAC-SHA256; the header contract and a verification recipe are in Server-rendered variants. Verify edge-forwarded assignments in Nitro before your universal plugin consumes them. Keep the ingest token in private runtime config.
// server/middleware/apex-assignments.ts
import { readServerAssignments } from "@drip-apex/sdk/vue";
export default defineEventHandler(async (event) => {
const ingestToken = useRuntimeConfig(event).apexIngestToken;
const result = readServerAssignments(
getRequestHeaders(event),
{ drip_uid: getCookie(event, "drip_uid") },
{
// Omit the token when it is not configured: forwarded headers are then
// rejected with `no_token`, while cookie-only bucketing still works.
...(typeof ingestToken === "string" && ingestToken ? { ingestToken } : {}),
// Your cached list of this shop's running experiments, filtered to the
// ones eligible for this request. It supplies control status, values and
// flag keys; without it every forwarded assignment counts as control.
experiments: await eligibleApexExperiments(event),
},
);
event.context.apexAssignments = result;
if (result.rejected) console.warn("Apex assignments rejected:", result.rejected);
});Rejected headers render defaults with no assignments; the middleware logs only the rejection reason. Attribution-only entries (attributableOnly: true) are dropped from rendering. Forwarded headers contain IDs, not flag metadata: pass eligible experiments to derive control status, variation values, and feature flags. Experiments missing from experiments are treated as control (isControl: true, index: 0, undefined value and feature flag), so flags fail closed while useApexVariant still exposes the forwarded variation id.
For cookie-only bucketing, pass experiments in the third argument containing only experiments already eligible for this request. It uses the same visitor and variation hashes as the client SDK. It does not implement browser targeting or sticky assignment storage; use forwarded decisions for those cases. No header and no cookie returns no assignment and leaves bucketing to the client.
<script setup lang="ts">
import { useApexVariant } from "@drip-apex/sdk/vue";
const variant = useApexVariant("hero");
</script>
<template><h1>{{ variant?.id === 'treatment' ? 'New headline' : 'Original headline' }}</h1></template>The plugin serializes verified decisions in useState("apex-assignments"). useApexVariant uses that payload for the first client render, even if the snippet has already loaded, then follows live runtime decisions. Never serialize the ingest token.
See Vue and Nuxt bindings for all composables and the provider boundary.
Preserve revenue attribution
Shopify's cartAttributesUpdate mutation replaces the full attributes array. In a Nuxt composable, read the cart's current attributes and merge the Apex stamp before every update. Re-sync after cartCreate and later cart mutations because an assignment can arrive after the first cart write.
export function useApexCartAttribution(storefrontClient) {
async function syncStandardAttribution(cartGid) {
const { cart } = await storefrontClient.query(CART_ATTRIBUTES_QUERY, {
variables: { cartId: cartGid },
});
const attributes = window.drip?.mergeCartAttributes?.(cart?.attributes)
?? cart?.attributes
?? [];
const result = await storefrontClient.mutate(CART_ATTRIBUTES_UPDATE, {
variables: { cartId: cartGid, attributes },
});
return result;
}
return { syncStandardAttribution };
}Call syncStandardAttribution(cart.id) after cartCreate succeeds and after each Storefront API cart mutation that may follow a new assignment.
Charge-affecting experiments must use the signed flow. For price, shipping, offer, or any other test that changes what the customer is charged, write every attribute returned by window.drip.getSignedCartAttributes(cartGid), then call confirmCartAttributesWritten(cartGid) only after cartAttributesUpdate succeeds with no userErrors. Never run an unsigned mergeCartAttributes() re-sync after a signed write on the same cart: it replaces the signed authorization, and commerce authorization fails closed at checkout.
getSignedCartAttributes returns only the Apex entries. cartAttributesUpdate replaces
the cart's full attributes array, so merge the signed entries with the cart's other
attributes before writing — never write the signed list alone, or you erase gift notes
and other apps' attributes. An empty signed result is fail-closed: skip the write
entirely and try again on the next cart mutation.
export function useApexSignedCartAttribution(storefrontClient) {
async function syncSignedAttribution(cartGid) {
const signed = (await window.drip?.getSignedCartAttributes?.(cartGid)) ?? [];
if (signed.length === 0) {
// Fail-closed: the SDK is not loaded yet (e.g. consent-gated loader) or
// authorization is not ready. Do not write, and do not substitute
// unsigned attributes on this cart.
return null;
}
const { cart } = await storefrontClient.query(CART_ATTRIBUTES_QUERY, {
variables: { cartId: cartGid },
});
const signedKeys = new Set(signed.map((entry) => entry.key));
const preserved = (cart?.attributes ?? []).filter(
(attribute) => !signedKeys.has(attribute.key),
);
const result = await storefrontClient.mutate(CART_ATTRIBUTES_UPDATE, {
variables: { cartId: cartGid, attributes: [...preserved, ...signed] },
});
const userErrors = result?.cartAttributesUpdate?.userErrors;
if (!Array.isArray(userErrors)) {
throw new Error("Shopify did not return cart attribute write status");
}
if (userErrors.length === 0) {
const acknowledged = window.drip.confirmCartAttributesWritten(cartGid);
if (!acknowledged) {
// The SDK rejected the acknowledgement (e.g. the staged treatment went
// stale while the write was in flight). Authorization stays pending —
// re-run this sync on the next cart mutation.
return null;
}
}
return result;
}
return { syncSignedAttribution };
}Cart attributes have no compare-and-set on the Storefront API, so a concurrent attribute write between the read and the update can be overwritten. Route all cart mutations in your app through one serialized cart store (queue writes; never fire cartAttributesUpdate concurrently), and re-run the sync after every later cart mutation — the same rule as the standard flow above.
Keep the signed and unsigned helpers on separate code paths. Orders can then be attributed server-side through Shopify order webhooks.
Verify the installation
- Publish an active experiment that targets a page in the Nuxt storefront.
- Open a fresh browser session. If OneTrust is enabled, exercise the consent state you intend to test.
- Confirm that the visit receives an experiment assignment and that the assigned variation appears.
- Navigate to another Nuxt route without a full reload and confirm another pageview arrives.
- In Apex, open Installation for the shop and run the verification check. Then open the experiment's results dashboard and confirm the visit and pageview are present.