Docs
Install

Drop the SDK into your app.

Pick a package manager, wrap the provider, ship a sign-in. Five minutes if you know your stack.

How it fits together

Your app lives on its own origin; elvix lives on elvix.is. Browsers block third-party cookies, so elvix authenticates cross-origin with a session token: sign-in hands you a token, the SDK stores it and sends it as Authorization: Bearer on every call, and your backend verifies it against elvix. No cookies cross the origin boundary, no CORS credentials, no shared domain required.

Get your clientId

Every snippet below assumes you've already minted a clientId for your app. Head to elvix.is/account/apps, create (or open) an Application, copy the clientId from Credentials, and add it to your project as NEXT_PUBLIC_ELVIX_CLIENT_ID. The NEXT_PUBLIC_ prefix is required so the browser bundle can read it. It's not a secret: only the bootstrap surface (brand chord, methods, copy) is exposed per Console policy.

  • Whitelist your dev origin. Under your Application → Settings → Allowed origins, add http://localhost:<port> for every port you run on (http://localhost:3000, http://localhost:3300, …). Without this, the SDK bootstrap call from the browser comes back 403 origin_not_allowed and <ElvixSignIn> renders an error state (not silent failure).
  • Add your production origin the same way (https://app.example.com). One row per scheme+host+port; trailing slashes are stripped server-side.
  • Self-hosted / dev mirror? Set NEXT_PUBLIC_ELVIX_BASE_URL too (e.g. https://elvix.edvone.dev) and pass it as <ElvixProvider baseUrl={…}>. The default origin is https://elvix.is.

Where does the token live? The SDK keeps the session token in an in-memory store inside <ElvixProvider> so its own components and hooks (sign-out, presence, role hooks) work without round-tripping the cookie. The elvix_token cookie you set in step 3 is only for your server components to read with cookies().get(...). The SDK keeps an in-memory copy; you mirror it into the cookie in step 3. On ban/revoke the lifecycle watcher clears both. <ElvixLifecycleWatcher cookieName="elvix_token"> defaults to the same name, so when the watcher clears the in-memory copy on a ban / revoke, your cookie clears too.

Environment variables

Two env vars cover every integration. Both are NEXT_PUBLIC_ so the browser bundle can read them; neither is a secret. The clientId only unlocks the bootstrap surface (brand chord, methods, copy) per Console policy, and the baseUrl is a routing hint.

VariableRequiredPurpose
NEXT_PUBLIC_ELVIX_CLIENT_IDYesYour Application's clientId from Console → Credentials. Read by <ElvixProvider> in the browser and by verifyElvixToken on the server.
NEXT_PUBLIC_ELVIX_BASE_URLNoOverride the elvix origin when self-hosting or pointing at a dev mirror (e.g. https://elvix.edvone.dev). Defaults to https://elvix.is. Pass it to <ElvixProvider baseUrl={…}> and to verifyElvixToken({ baseUrl }).

NEXT_PUBLIC_* vars are inlined into the bundle at BUILD time, not read at runtime. So the variable must be present in the environment that runs next build for production (your CI / Docker build-args / deploy pipeline), NOT only in a local .env. If it's missing at build, the browser bakes undefined and <ElvixProvider> falls back to a generic sign-in. SYMPTOM: your prod sign-in shows the generic "your app" title, a single-letter logo tile, email-only (no Google/GitHub), and no branding, even though it works locally and the Console is configured. FIX: add NEXT_PUBLIC_ELVIX_CLIENT_ID (and NEXT_PUBLIC_ELVIX_BASE_URL if not elvix.is) to your production build env, then rebuild. Verify by grepping your deployed JS bundle for the clientId, it must be present.

1. Install the package

$ bun add @elvix.is/sdk

2. Wrap your app

Drop the provider at your tree root. Pass your clientId (Console → your Application → Credentials). The SDK talks to elvix.is automatically and the provider fetches the brand chord, methods, and copy from elvix. No other props required. Once the user signs in, the SDK carries a bearer token cross-origin (no third-party cookies).

app/layout.tsx
import { ElvixProvider } from "@elvix.is/sdk/react";
import "@elvix.is/sdk/styles.css"; // REQUIRED — the sign-in surface renders unstyled without it.
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<ElvixProvider clientId={process.env.NEXT_PUBLIC_ELVIX_CLIENT_ID!}>
{children}
</ElvixProvider>
</body>
</html>
);
}

Self-hosting / dev mirrors (optional)

By default, <ElvixProvider> and verifyElvixToken talk to https://elvix.is. Override the origin only when pointing at a self-hosted elvix instance or a dev mirror like elvix.edvone.dev. Pass baseUrl to the provider and baseUrl in the verify options. Most apps never need this.

app/layout.tsx (self-hosted)
<ElvixProvider
clientId={process.env.NEXT_PUBLIC_ELVIX_CLIENT_ID!}
baseUrl={process.env.NEXT_PUBLIC_ELVIX_BASE_URL} // optional, defaults to https://elvix.is
>
{children}
</ElvixProvider>

3. Drop the sign-in

Mount a sign-in surface anywhere. The form renders every factor your Console enabled (passkey, email OTP, Google). On success, onResult hands you r.token (the end-user session token). The SDK already keeps a copy for its own components and hooks; you forward your copy by setting an elvix_token cookie so the server can verify each request. See the Cookie round-trip subsection below for the full contract.

app/sign-in/page.tsx
"use client";
// <ElvixSignInForm> is the polished, branded card (your logo + the methods you
// turned on in Console + the "Secured by elvix" chip). It writes no styling of
// its own: configure the look + methods once in Console -> Sign-in methods.
import { ElvixSignInForm } from "@elvix.is/sdk/react";
import { useRouter } from "next/navigation";
export default function SignInPage() {
const router = useRouter();
return (
<ElvixSignInForm
navigate={false}
onResult={(r) => {
if (!r.ok) return console.warn(r.error, r.message);
// r.token is the end-user session token. The SDK already keeps an
// in-memory copy for its own components and hooks; the cookie below
// is purely so YOUR server components can read the same token via
// `cookies().get("elvix_token")`. Guard on r.ok && r.token before
// writing — an ok:true with no token means the SDK already wrote a
// first-party cookie same-origin and you don't need to mirror it.
//
// Cookie contract (must match the server-side read below):
// - name: elvix_token
// - samesite: lax
// - secure: PRODUCTION ONLY. Browsers silently drop secure cookies
// on http://localhost, so omit it in dev.
if (r.ok && r.token) {
// `secure` is gated on the live protocol: browsers silently drop
// secure cookies on http://localhost, so omitting it in dev keeps
// the cookie landing and the server able to read it.
document.cookie = `elvix_token=${r.token}; path=/; max-age=2592000; samesite=lax${typeof location !== "undefined" && location.protocol === "https:" ? "; secure" : ""}`;
}
router.push(r.redirect ?? "/dashboard");
}}
/>
);
}

4. Verify a session on your server

Call verifyElvixToken({ token, clientId }) from @elvix.is/sdk/server inside a server component. It POSTs the token to https://elvix.is/api/v1/session and returns the live user envelope (roles, scopes, memberships). Because elvix re-checks live status on every call, a banned or paused user verifies as ok:false here, so you enforce bans server-side for free. Verify per request, or cache for a few seconds.

app/dashboard/page.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { verifyElvixToken } from "@elvix.is/sdk/server";
// REQUIRED — without this, Next.js statically hoists the page at build
// time and the cookies() read never re-evaluates per request.
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
// Server-side read of the cookie your sign-in page set at step 3.
const token = (await cookies()).get("elvix_token")?.value;
if (!token) redirect("/sign-in");
// POSTs the token as a Bearer to https://elvix.is/api/v1/session. elvix
// re-checks the session AND the live user/membership status on every call,
// so a banned, paused, or signed-out user comes back ok:false within one
// request. Never throws on auth failure — only on network/timeout.
const result = await verifyElvixToken({
token,
clientId: process.env.NEXT_PUBLIC_ELVIX_CLIENT_ID!,
// add baseUrl: process.env.NEXT_PUBLIC_ELVIX_BASE_URL when self-hosting
});
if (!result.ok) redirect("/sign-in");
return (
<AppShell userId={result.user.id}>
<h1>Hello {result.user.name ?? result.user.email}</h1>
</AppShell>
);
}

export const dynamic = "force-dynamic" is mandatory on any page that reads cookies. Without it Next.js statically hoists the route, the cookies() call is evaluated once at build, and every visitor sees the same stale result.

Cookie round-trip

The friction every new integration hits: the SDK hands you r.token in the browser; your server needs it on the next navigation. The contract is one cookie with one name, set on the client and read on the server. No invented session keys, no signed envelopes.

  • Name: elvix_token. Standardised so SDK docs, server snippets, and <ElvixLifecycleWatcher> all read the same key.
  • Set (client, after sign-in): guard on r.ok && r.token first, then document.cookie = "elvix_token=<token>; path=/; samesite=lax" + (location.protocol === "https:" ? "; secure" : ""). Skipping the guard writes undefined into the cookie on ok:false callbacks and the server reads garbage.
  • Read (server, every request): (await cookies()).get("elvix_token")?.value inside a server component or route handler.
  • samesite=lax: sends the cookie on top-level navigations (so the dashboard page sees it immediately after redirect) but blocks cross-site POSTs (CSRF-safe by default).
  • secure flag: production-only. Browsers silently DROP cookies with secure set over http://localhost, so the cookie never lands and your server reads undefined forever. Gate the flag on location.protocol === "https:".

If you change the cookie name, change it in three places: the document.cookie write in onResult, the cookies().get(...) read in every server page, and the cookieName prop on <ElvixLifecycleWatcher>. Sticking to elvix_token keeps the SDK and your code in sync without per-app config.

Redirect-OAuth round-trip (Google)

When a user signs in with Google from your origin, the SDK navigates to https://elvix.is/api/auth/google/start?clientId=<id>&returnTo=<your sign-in page>. elvix runs the OAuth dance with Google (your customer domain is NEVER added to Google's authorized JS origins; the redirect_uri is always https://elvix.is/api/auth/google/callback). On success, elvix bounces the browser back to your returnTo page with the end-user session token in the URL fragment: https://your.app/sign-in#elvix_token=<token>. The fragment never reaches the server, so the token never appears in your access logs or referrers.

From your code's perspective, the contract is the same as in-frame sign-in. <ElvixProvider> reads #elvix_token=... on mount, stores the token in the SDK's in-memory store, strips the fragment from history.replaceState, and dispatches an elvix:return-token event. <ElvixSignIn> / <ElvixSignInForm> listen and fire your onResult (and onAuthenticated, if set) with the same { ok: true, token, redirect } payload an in-frame OTP / passkey success carries. Your existing handler runs unchanged.

Two requirements: (1) <ElvixProvider> must mount on the page Google redirects back to (typically your /sign-in route layout). If it does not mount there, import consumeElvixReturnToken from @elvix.is/sdk/react and call it once in a useEffect. (2) <ElvixSignIn> or <ElvixSignInForm> must render on that same page so its listener picks up the dispatched event.

onAuthenticated was wired into the redirect-return path in SDK 0.7.6, so 0.7.6 is the historical minimum version for that fix: a host on 0.7.5 that uses onAuthenticated (not onResult) to do a server-side token exchange before navigating leaves the user stranded on /sign-in. That is a floor, not the pin to ship: install the current @elvix.is/sdk@^0.9.0 (it includes the redirect fix plus the device-login helpers).

5. Live roles + instant logout in the browser

Roles, scopes, and memberships change at runtime (no token swap, no forced re-login). The useUserRoles / useUserScopes / useUserMemberships hooks poll elvix every ~7s and expose the current slugs. Mount <ElvixLifecycleWatcher> once on any signed-in surface: when an admin bans, pauses, or deletes the user, it clears the token and signs them out within ~9s, matching elvix's own first-party surfaces. (Polling, not SSE, because EventSource can't carry the bearer token.)

components/app-shell.tsx
"use client";
import {
ElvixLifecycleWatcher,
useElvixApp,
useUserRoles,
} from "@elvix.is/sdk/react";
export function AppShell({ userId, children }: { userId: string; children: React.ReactNode }) {
const app = useElvixApp(); // bootstrap envelope: carries applicationId
// The SDK short-circuits on empty applicationId — no need to gate the call
// yourself. While bootstrap resolves, useUserRoles returns slugs:[] +
// loading:true, so role checks below are safe with no flash of authorised UI.
const { slugs: roles } = useUserRoles({
applicationId: app?.applicationId,
userId,
});
return (
<>
{/* Polls elvix ~7s. The moment the user is banned / paused / deleted, it
clears the cookie named below AND the in-memory token, then reloads
the page so your server-side gate redirects to /sign-in. ~9s
worst-case logout. cookieName must match the cookie you set in
step 3 — keep it as "elvix_token" unless you renamed it everywhere. */}
<ElvixLifecycleWatcher cookieName="elvix_token" />
{roles.includes("admin") && <AdminMenu />}
{children}
</>
);
}

Canonical package: @elvix.is/sdk on the public npm registry. One package, three subpath imports: @elvix.is/sdk/react for components and hooks, @elvix.is/sdk/server for verifyElvixToken, @elvix.is/sdk/types for shared TypeScript types.

The SDK source ships under MIT, reproducible from tagged commits in the public repo: no closed-source binaries, no telemetry, no opaque background calls. Installing the package does not auto-authorise you. You still need a clientId from a workspace we provisioned for you.