Install @xenia-martech/sdk-js and connect any web stack to Xenia, from a plain script tag to Next.js server components.
@xenia-martech/sdk-js is the official Xenia SDK for web channels. Wrap your app
once, mark up a few elements, and the SDK handles identity, journey attribution,
event delivery, in-page personalization, and analytics for you.
The values a journey needs (trigger ids, request fields, content keys) live in your Xenia account, not in your code, and a guessed one compiles but never fires. Connect the Xenia MCP server first and your assistant reads the real values and does the wiring for you.
One client does four jobs, all sharing the same identity and attribution state:
react and react-dom 18 or later are optional peers, needed only for the React
bindings. next is an optional peer for the Next.js helpers.
No package manager? Load the prebuilt bundle from a CDN instead. Pin the major version in production so a future release cannot change your site unannounced:
<script src="https://cdn.jsdelivr.net/npm/@xenia-martech/sdk-js@1/dist/xenia-sdk.iife.js"></script>A mostly server-rendered app that needs one interactive widget uses /server for
the page and mounts the React provider inside just that one component. See
a scoped client island.
Every client needs your workspace id (tenantId) and environment key
(environmentKey). Both are shown in the app next to your API tokens. Each
environment has its own values, so staging and live never mix.
Wrap your app once. Identity, attribution and data-xenia auto-tracking start
automatically.
import { XeniaProvider, useTrack } from "@xenia-martech/sdk-js/client/react";
export default function App() {
return (
<XeniaProvider tenantId="<workspace id>" environmentKey="<environment>">
<button data-xenia="checkout&click">Checkout</button>
<YourApp />
</XeniaProvider>
);
}
function AddToCart({ id }: { id: string }) {
const track = useTrack();
return (
<button onClick={() => track("add_to_cart", { productId: id }, { trigger: true })}>
Add to cart
</button>
);
}Create an SDK token in the app under API Tokens. It belongs to one workspace and one environment. Tracking events works without one, unless the journey's trigger requires auth. Personalization always requires one.
Keep the token you create in the app as a server-side secret. Browser code gets a short-lived token from a route on your own server instead.
The SDK ships that route ready-made:
// app/api/xenia-token/route.ts (any framework with route handlers works the same way)
import { createTokenRoute } from "@xenia-martech/sdk-js/server";
export const GET = createTokenRoute({ sdkToken: process.env.XENIA_SDK_TOKEN! });Then point the browser client at it with getToken. The SDK caches the token and
fetches a new one shortly before it expires.
<XeniaProvider
tenantId="<workspace id>"
environmentKey="<environment>"
getToken={() => fetch("/api/xenia-token").then((r) => r.json()).then((r) => r.token)}
>Not on a framework with route handlers? Call refreshSdkToken() from any HTTP
handler (Express, Fastify, a Lambda) to do the same exchange.
The server client is stateless: there is no window, so you bind each request's
visitor and page once, and every call on that client carries them.
import { createXeniaServer, getVisitorId } from "@xenia-martech/sdk-js/server";
import { contextFromUrl } from "@xenia-martech/sdk-js";
const xenia = createXeniaServer({
tenantId: process.env.XENIA_TENANT_ID!,
environmentKey: process.env.XENIA_ENV_KEY!,
token: process.env.XENIA_SDK_TOKEN,
visitorId: getVisitorId(request.headers.get("cookie")) ?? undefined,
context: contextFromUrl(request.url, request.headers.get("referer") ?? undefined),
});
const offer = await xenia.webhook("<trigger id>", { source: "backend" }, { fallback: null });
const hero = await xenia.resolve("homepage-hero", { merge: true, fallback: null });The browser SDK writes the visitor id to a first-party xenia_vid cookie. Reading
it back with getVisitorId() is what lets a server-side purchase be credited to a
journey the shopper entered in the browser.
createXeniaServer({ consent }) is ignored, because the server has no session to
hold it in. Pass consent on every track() call, or wrap track once in a
helper that adds it. Without it, analytics events are dropped. Journey triggers
still fire.
@xenia-martech/sdk-js/next replaces the cookie and URL plumbing with two calls.
// proxy.ts (middleware.ts before Next 16)
import type { NextRequest } from "next/server";
import { xeniaProxy } from "@xenia-martech/sdk-js/next";
// Mints the xenia_vid cookie for new visitors and stamps the page URL on the request
export const proxy = (request: NextRequest) => xeniaProxy(request);// Any Server Component, Server Action or Route Handler
import { consentFromNext, xeniaServerFromNext } from "@xenia-martech/sdk-js/next";
const xenia = await xeniaServerFromNext(config); // bound to this visitor and page
const res = await xenia.webhook("<trigger id>", {}, { fallback: null });
// In a Server Action, after a form submit
const consent = await consentFromNext();
if (consent.analytics) {
await xenia.identify({ email });
await xenia.track("formSubmitted", { eventId: `contact-${ref}`, consent });
}Running your own consent banner without the browser SDK? Write the cookie the
server reads with consentCookie():
import { consentCookie } from "@xenia-martech/sdk-js";
document.cookie = consentCookie({ analytics: true, consentSource: "banner" });In a server-rendered app, mount the provider inside the one interactive component
rather than at the root. Its getToken calls your token route.
"use client";
import { XeniaProvider, useWebhook } from "@xenia-martech/sdk-js/client/react";
function OfferButton({ triggerId }: { triggerId: string }) {
const { send, isLoading } = useWebhook(triggerId);
return (
<button onClick={() => void send({ page: "storefront" })} disabled={isLoading}>
{isLoading ? "Finding your offer…" : "Reveal my offer"}
</button>
);
}
export function WelcomeOffer({ triggerId }: { triggerId: string }) {
return (
<XeniaProvider tenantId="<workspace id>" environmentKey="<environment>" getToken={getToken}>
<OfferButton triggerId={triggerId} />
</XeniaProvider>
);
}These options apply to XeniaProvider, createXeniaClient, createXeniaServer
and Xenia.init.
Browser-only options:
Nothing the SDK does blocks your render. Requests time out and retry with backoff,
and a hook that fails leaves data as null so your default content keeps
showing. Server-side, pass { fallback: null } to webhook(), resolve() or
resolveBatch() and the call returns null on any failure instead of throwing.
During an outage, turn on circuitBreaker so visitors stop waiting out the
timeout.