Integrate

React

Add the React components to your application. Forward a path on your own origin, keep the end user's session in your app, call your own backend with the same token, and theme the components.

@shiprev/react gives your React application sign-in and sign-up forms, a pricing table, a subscription panel and a usage meter. This page shows how to wire it into an application: where its requests go, who holds the session, and how the same session reaches your own backend. What each component shows and does is covered in the guides it belongs to, linked at the end.

This page covers version 0.1.0-beta.0.

Install

bash
npm install @shiprev/react@beta

React 19 is the only peer dependency. The package has no runtime dependencies, and loads no fonts, images or icon sets.

How the pieces talk

text
browser ──▶ <your origin>/shiprev/public/v1/usage/summary ──forward──▶ https://<brand domain>/rest/public/v1/usage/summary
        └─▶ <your origin>/api/reports ───────────────────────────────▶ your backend, which verifies the same token

The components only ever call your page's own origin, under a path you choose, with credentials: 'omit'. Your server forwards that path to your brand domain under /rest. The browser never talks to ShipRev directly, so there is no cross-origin setup, and the brand domain lives in your server's configuration only.

Forward a path on your own origin

Forward <your origin>/shiprev/* to https://<brand domain>/rest/*, with Host set to the brand domain: ShipRev picks your application from the host name it receives. Drop your site's cookies on the way, so your own sessions never reach ShipRev, and drop Set-Cookie on the way back. The route handler below also keeps the end user's refresh token out of the browser's JavaScript, in an HttpOnly cookie of your own origin.

// Next.js App Router: forwards /shiprev/* on your origin to your brand domain under /rest/*.
// Only the listed headers are sent, so your site's cookies stay with you. The refresh token never
// reaches the browser's JavaScript: it is moved out of every issued session into an HttpOnly cookie,
// and put back into the two requests that need it.
const BRAND_ORIGIN = brandOrigin(process.env.SHIPREV_BRAND_ORIGIN);
const FORWARDED_HEADERS = ['authorization', 'content-type', 'accept', 'x-request-id'];
const REFRESH_COOKIE = 'shiprev_refresh';
const REFRESH_COOKIE_PATH = '/shiprev'; // the forwarded prefix; the cookie itself is never forwarded
const NEEDS_REFRESH_TOKEN = new Set(['public/v1/auth/refresh', 'public/v1/auth/logout']);

/** Fails at start-up rather than forwarding your end users' tokens to a wrong or missing address. */
function brandOrigin(value: string | undefined): string {
  if (!value) throw new Error('SHIPREV_BRAND_ORIGIN is not set');
  const url = new URL(value); // throws on a malformed value
  const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
  if (url.protocol !== 'https:' && !loopback) throw new Error('SHIPREV_BRAND_ORIGIN must be an https:// origin');
  return url.origin;
}

function refreshCookie(value: string, maxAge?: number): string {
  const age = maxAge === undefined ? '' : `; Max-Age=${maxAge}`;
  return `${REFRESH_COOKIE}=${value}; Path=${REFRESH_COOKIE_PATH}; HttpOnly; Secure; SameSite=Strict${age}`;
}

function readCookie(request: Request, name: string): string | undefined {
  for (const part of (request.headers.get('cookie') ?? '').split(';')) {
    const [key, ...rest] = part.trim().split('=');
    if (key === name) return rest.join('=');
  }
  return undefined;
}

async function forward(request: Request, { params }: { params: Promise<{ path: string[] }> }) {
  const { path } = await params; // segments arrive decoded: %2E%2E%2Ffoo is `../foo`
  // Refuse `.` and `..`, and any segment that decodes to something containing / or \.
  if (path.some((segment) => segment === '.' || segment === '..' || /[/\\]/.test(segment))) {
    return new Response(null, { status: 400 });
  }
  const route = path.join('/');
  const target = new URL(`/rest/${path.map(encodeURIComponent).join('/')}`, BRAND_ORIGIN);
  if (!target.pathname.startsWith('/rest/public/')) return new Response(null, { status: 400 });
  target.search = new URL(request.url).search;

  const headers = new Headers();
  for (const name of FORWARDED_HEADERS) {
    const value = request.headers.get(name);
    if (value) headers.set(name, value);
  }
  let body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.text();
  if (NEEDS_REFRESH_TOKEN.has(route)) {
    let fields: Record<string, unknown>;
    try {
      fields = body ? JSON.parse(body) : {};
    } catch {
      return new Response(null, { status: 400 });
    }
    body = JSON.stringify({ ...fields, refresh_token: readCookie(request, REFRESH_COOKIE) ?? '' });
    headers.set('content-type', 'application/json');
  }

  const upstream = await fetch(target, { method: request.method, headers, body, redirect: 'manual' });
  const answer = new Headers(upstream.headers);
  for (const name of ['set-cookie', 'content-encoding', 'content-length']) answer.delete(name); // the body arrives decoded
  if (route === 'public/v1/auth/logout') answer.append('set-cookie', refreshCookie('', 0));

  if (!upstream.headers.get('content-type')?.includes('application/json')) {
    return new Response(upstream.body, { status: upstream.status, headers: answer });
  }
  const json = await upstream.json();
  const refreshToken = json?.data?.refresh_token;
  if (typeof refreshToken === 'string') {
    delete json.data.refresh_token; // the browser gets the access token only
    answer.append('set-cookie', refreshCookie(refreshToken));
  }
  return new Response(JSON.stringify(json), { status: upstream.status, headers: answer });
}

export { forward as DELETE, forward as GET, forward as PATCH, forward as POST, forward as PUT };

Keep the brand domain in configuration: a rehearsal and production use different ones. Both examples refuse to start without SHIPREV_BRAND_ORIGIN, and the route handler also without an https:// origin (localhost and 127.0.0.1 excepted, for a local rehearsal), rather than forward your end users' tokens to a placeholder. The components call only public API paths, such as <apiBase>/public/v1/usage/summary, which is why the route handler refuses anything outside /rest/public/, and any path segment that is . or .. or decodes to something containing / or \. A plain rewrites() entry in next.config.ts forwards the browser's cookies unchanged, so use a route handler as above rather than a rewrite.

What the route handler does with the session:

RequestWhat it does
Any answer that carries refresh_token, such as sign-in and renewalTakes the refresh token out of the body and sets it as the cookie shiprev_refresh: HttpOnly, Secure, SameSite=Strict, sent only with requests under /shiprev, where the route handler reads it and never forwards it
POST …/auth/refreshPuts the cookie's refresh token into the body. The region rotates it, and the new one replaces the cookie
POST …/auth/logoutPuts the cookie's refresh token into the body and clears the cookie, whatever the region answers. The region revokes the session when the request carries a valid access token and the latest refresh token, which is what the store below sends

The Vite proxy does none of this. With it, a session lasts as long as its access token, at most 15 minutes, and then the end user signs in again.

/shiprev is only the prefix the examples use. Pick any path your application does not already serve, give the provider the same one, and change REFRESH_COOKIE_PATH to match.

Keep the session in your application

The components never read or write localStorage, sessionStorage or cookies, and keep no token. The sign-in form hands you the issued session, and the provider asks you for the access token before each request. Keep the access token in memory: then it is not left in storage for a script injected into your page to read later, and behind the route handler the session carries no refresh token at all. This narrows what such a script can take, not what it can do: while it runs it can still use the session, and renew it through the cookie, so keep scripts out of your page as you would without ShipRev. A small store is enough:

session.ts
import type { IssuedSession } from '@shiprev/react';

// The access token lives in memory only. The refresh token never reaches this code: the route
// handler that forwards /shiprev keeps it in an HttpOnly cookie, so renewal sends no token.
let accessToken: string | null = null;
let generation = 0; // moves on at sign-out, so that a renewal started before it is discarded
let signingOut = false; // while true, no new renewal starts
let renewing: Promise<string | null> | null = null;
const listeners = new Set<() => void>();

function set(next: string | null) {
  accessToken = next;
  listeners.forEach((listener) => listener());
}

function refresh(): Promise<string | null> {
  const started = generation;
  const request = fetch('/shiprev/public/v1/auth/refresh', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: '{}',
  })
    .then(async (response) => (response.ok ? ((await response.json()).data.access_token as string) : null))
    .catch(() => null)
    .then((token) => {
      if (generation !== started) return null; // signed out (or in again) meanwhile: drop it
      set(token);
      return token;
    })
    .finally(() => {
      if (renewing === request) renewing = null;
    });
  renewing = request;
  return request;
}

export const session = {
  accessToken: () => accessToken,
  signedIn: () => accessToken !== null,
  subscribe(listener: () => void) {
    listeners.add(listener);
    return () => void listeners.delete(listener);
  },
  /** Pass to onSignedIn. */
  start(issued: IssuedSession) {
    generation += 1;
    signingOut = false;
    set(issued.access_token);
  },
  /** Renews from the cookie, once however many requests ask; also restores the session after a reload. */
  renew(): Promise<string | null> {
    if (signingOut) return Promise.resolve(null);
    return renewing ?? refresh();
  },
  /**
   * Revokes the session at the region, then forgets it. It first lets a renewal in flight finish, or
   * makes one, so that the cookie holds the latest refresh token and the access token is fresh: the
   * region revokes a session only for a valid access token and the refresh token it issued last.
   */
  async signOut() {
    signingOut = true;
    const current = accessToken;
    const token = (await (renewing ?? refresh())) ?? current;
    generation += 1;
    renewing = null;
    set(null);
    // The route handler clears the cookie whatever the region answers.
    await fetch('/shiprev/public/v1/auth/logout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
      body: '{}',
    }).catch(() => undefined);
  },
};

An access token expires within 15 minutes; see Sessions and tokens. After a reload the memory is empty: renew() gets a new access token from the cookie, which is why the example below calls it once when the page loads. Signing out first lets a renewal that is on its way finish, or makes one, so that the logout carries a valid access token and the latest refresh token, even when the access token had expired; no new renewal starts meanwhile. The region then revokes the session, and a renewal sent after that finds it revoked and sets no cookie. The store coordinates renewals in its own page only: if another tab of your application renews at the same moment, the logout can carry a refresh token that has just been replaced, the region refuses it, and that tab's answer sets the cookie again. If the region refuses the logout, the store and the cookie are cleared all the same; if the region cannot be reached, the route handler fails before it clears the cookie, and only the store is cleared.

Put the provider around the components

App.tsx
import { PricingTable, ShipRevProvider, SignInForm, SubscriptionPanel, UsageMeter } from '@shiprev/react';
import '@shiprev/react/styles.css';
import { useEffect, useSyncExternalStore } from 'react';

import { session } from './session';

export function App() {
  const signedIn = useSyncExternalStore(session.subscribe, session.signedIn);
  useEffect(() => void session.renew(), []); // restores the session from the cookie after a reload

  return (
    <ShipRevProvider apiBase="/shiprev" getAccessToken={session.accessToken} onUnauthorized={session.renew}>
      {signedIn ? (
        <>
          <UsageMeter />
          <SubscriptionPanel pricingUrl="#plans" />
          <div id="plans">
            <PricingTable />
          </div>
          <button onClick={() => void session.signOut()}>Sign out</button>
        </>
      ) : (
        <SignInForm onSignedIn={session.start} />
      )}
    </ShipRevProvider>
  );
}
PropWhat it does
apiBaseThe path on your own origin that you forward. It must start with /; an absolute URL makes the provider throw
getAccessTokenReturns the current access token, or null when nobody is signed in; the data components then show their signed-out state without sending a request. May return a promise
onUnauthorizedCalled when a request is answered 401. Return a renewed access token and the request is sent once more; return null, or throw, to show the signed-out state. A second 401 is not retried, and concurrent 401s share one call. Optional
appearanceThe theme; see below

Every component must be inside a ShipRevProvider. The package's entry starts with 'use client', so a Next.js App Router page can import it directly.

The region confirms a new email address before it issues a session, and 0.1.0-beta.0 cannot send an invitation, so a sign-up through SignUpForm does not complete: the region creates the account, emails a 6-digit code and answers 403 EMAIL_VERIFICATION_REQUIRED, which the form reports through onError without asking for the code, and onSignedUp is not called. SignInForm likewise refuses an account whose email is not verified yet. Until a version with this step is published, send end users to the hosted pages to sign up and for their first sign-in; see Hosted pages, components or your own. This is why the example above renders SignInForm only.

Call your own backend with the same session

Your backend verifies the same access token with the Server SDK. Send it as a bearer token, only to your own origin, and renew once on a 401, sharing the renewal with the components:

api.ts
import { session } from './session';

/** Calls your own backend, on this origin only, with the end user's access token; renews once on a 401. */
export async function api(path: string, init: RequestInit = {}): Promise<Response> {
  // Only a path on this origin: never an absolute URL or a protocol-relative one such as //host.
  if (!path.startsWith('/') || path.startsWith('//') || path.startsWith('/\\')) {
    throw new Error(`api() takes a path on this origin, not ${path}`);
  }
  const url = new URL(path, window.location.origin);
  if (url.origin !== window.location.origin) throw new Error(`api() takes a path on this origin, not ${path}`);

  const send = (token: string | null) =>
    fetch(url, { ...init, headers: { ...init.headers, ...(token ? { Authorization: `Bearer ${token}` } : {}) } });
  const response = await send(session.accessToken());
  if (response.status !== 401) return response;
  const renewed = await session.renew();
  return renewed ? send(renewed) : response;
}

When an access token has expired, the components and your own calls fail with 401 at about the same time; the store above makes them share one renewal. Your backend's side is in NestJS.

UsageMeter reads the usage summary when it mounts. To show new figures after an action of the end user, such as an export, give it a key that changes after the action; the figures move once your backend's reported usage has been counted, a few seconds later.

Theme the components

styles.css is optional. Without it the components render unstyled, with stable sr-* class names. With it, every rule sits in the shiprev cascade layer, so your own unlayered styles win.

tsx
<ShipRevProvider
  apiBase="/shiprev"
  getAccessToken={session.accessToken}
  appearance={{ primaryColor: '#0f766e', radius: '6px', fontFamily: 'Inter, sans-serif', colorScheme: 'dark' }}
>
appearance keyCSS variableDefault
primaryColor--sr-color-primary#4f46e5
radius--sr-radius8px
fontFamily--sr-font-familythe system UI font stack
colorSchemedata-sr-scheme on the rootlight; 'dark' switches the default palette

--sr-color-text, --sr-color-muted, --sr-color-border, --sr-color-surface and --sr-color-danger can be set in your own CSS. Each component also takes className, added to its root element, and labels, to replace any of its English texts.

Two default texts describe what an action does, and do not hold in every case. Before a plan switch, PricingTable says only that the change takes effect right away: it does not say that the difference is charged at once, and when the payment does not go through, the change waits for it. Before a cancellation, SubscriptionPanel says that the subscription stays active until the end of the period, which holds for a subscription paid through checkout but not for one without a card, which ends at once. Replace them through labels with confirmSwitchPrompt and confirmCancelPrompt where they do not fit your plans.

Errors

Each component takes onError, called once for each failed request with a ShipRevError, while the component shows its own error state:

FieldWhat it holds
statusThe HTTP status; 0 when no response arrived
codeThe API's error code, HTTP_ERROR when the answer had no error body, NETWORK_ERROR when no response arrived
requestIdThe request_id of the answer's error body
detailsThe error's details, when the answer had any

The codes are listed in Error codes.

Check it

With your page served and forwarding to your brand domain:

  • Signing in with a verified account shows the usage meter, the subscription and the plans; the network panel shows only requests to your own origin.
  • The sign-in answer the browser receives has no refresh_token, and the browser holds a shiprev_refresh cookie marked HttpOnly.
  • With the access token made invalid, the components and your own calls recover through one POST /shiprev/public/v1/auth/refresh; after a reload, the page is still signed in.
  • After signing out, the cookie is gone and a reload shows the sign-in form.
  • Your backend refuses a call your end user's plan does not allow, and your frontend shows the plans instead.

Where to go from here

© 2026 ShipRev