Remix Integration#

Remix renders on the server first, then hydrates on the client. Follow this guide to ensure Pulsora initializes only in the browser and that you capture revenue metadata.

Install Dependencies#

npm install @pulsora/react @pulsora/core

Create environment variables:

PULSORA_PUBLIC_TOKEN=pub_123
PULSORA_SECRET_TOKEN=sec_123

Expose the public token to the client via loader.

Root Layout#

// app/root.tsx
import type { LinksFunction, LoaderFunctionArgs } from '@remix-run/node';
import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
  useLocation,
} from '@remix-run/react';
import { PulsoraProvider, usePageview } from '@pulsora/react';

export const loader = async ({ request }: LoaderFunctionArgs) => {
  return {
    pulsoraToken: process.env.PULSORA_PUBLIC_TOKEN,
  };
};

function Analytics() {
  const location = useLocation();
  usePageview({ trigger: location.pathname + location.search });
  return null;
}

export default function App() {
  const { pulsoraToken } = useLoaderData<typeof loader>();

  return (
    <html lang="en">
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        <PulsoraProvider config={{ apiToken: pulsoraToken }}>
          <Analytics />
          <Outlet />
        </PulsoraProvider>
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

Tracking Events#

// app/routes/pricing.tsx
import { useEvent } from '@pulsora/react';

export default function Pricing() {
  const trackEvent = useEvent();

  return (
    <button
      className="btn btn-primary"
      onClick={() => trackEvent('cta_click', { plan: 'pro' })}
    >
      Start Trial
    </button>
  );
}

Revenue Attribution with Stripe#

// app/routes/checkout.tsx (action handler)
import type { ActionFunctionArgs } from '@remix-run/node';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
});

export const action = async ({ request }: ActionFunctionArgs) => {
  const { priceId, visitor_fingerprint, session_id } = await request.json();

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: 'https://app.example.com/success',
    cancel_url: 'https://app.example.com/cancel',
    metadata: {
      visitor_fingerprint,
      session_id,
    },
  });

  return { sessionId: session.id };
};
// app/routes/api.stripe-webhook.ts
import type { ActionFunctionArgs } from '@remix-run/node';
import { Revenue } from '@pulsora/revenue';

const revenue = new Revenue({
  apiToken: process.env.PULSORA_SECRET_TOKEN!,
});

export const action = async ({ request }: ActionFunctionArgs) => {
  const payload = await request.json();

  if (payload.type === 'checkout.session.completed') {
    const session = payload.data.object;

    await revenue.track({
      visitor_fingerprint: session.metadata.visitor_fingerprint,
      session_id: session.metadata.session_id,
      customer_id: session.customer,
      amount: session.amount_total / 100,
      currency: session.currency.toUpperCase(),
      transaction_id: session.id,
      payment_processor: 'stripe',
      event_type: 'subscription',
      customer_subscription_id: session.subscription,
      is_recurring: true,
    });
  }

  return { received: true };
};

Troubleshooting#

  • Hydration errors — Ensure Pulsora code sits inside the provider and runs only in the browser.
  • Undefined token — Confirm your loader exposes PULSORA_PUBLIC_TOKEN before hydration.
  • Revenue validation errors — Verify Stripe metadata includes visitor_fingerprint and session_id.

Looking for more patterns? Check the generic custom integration guide →