@browsonic/remix
v1.2.19
Published
Remix adapter for @browsonic/sdk — route ErrorBoundary, action wrapper, client-entry capture. Re-exports @browsonic/react. Apache-2.0.
Downloads
519
Maintainers
Readme
@browsonic/remix
Remix adapter for @browsonic/sdk — a route ErrorBoundary component, action / loader wrappers that stamp remix.handler, an entry.client.tsx config helper, a route-hierarchy navigation breadcrumb hook, plus part of the @browsonic/react surface re-exported.
Status: released.
package.jsonis 1.2.15, and npm'slatestis the same 1.2.15 (published 2026-07-08; registry checked 2026-07-27). The0.1/0.2/0.3labels in the source doc comments are pre-1.0 development milestones, not releases — all three landed together in1.0.0(2026-05-07) and this package has never had a0.xrelease.Exports:
BrowsonicRouteErrorBoundary,captureRouteError,withBrowsonicRemixAction,withBrowsonicRemixLoader,bootstrapBrowsonic,useRemixNavigationBreadcrumbs,resolveSdk, plus the re-exported React names listed below. Nothing here imports@remix-run/*at runtime, and@remix-run/*appears in no dependency block at all — the Remix values the hook consumes are described by structural interfaces (NavigationLike,MatchLike) declared in this package — so the adapter is not tied to a particular Remix build mode.
Why this adapter
Remix's error model is route-scoped: each route module can export an ErrorBoundary component that the framework renders when the route's loader / action / component throws. We ship a component you hand that error to, which captures it on mount.
Remix delivers the route error through useRouteError(), not through props, so export { BrowsonicRouteErrorBoundary as ErrorBoundary } on its own captures nothing — call useRouteError() yourself and pass the value in, as the quickstart below does. Without an error prop the component only renders its fallback.
@browsonic/react is a peer dependency and part of its surface is re-exported here, so Remix consumers import from one package instead of two — the same subset @browsonic/nextjs re-exports. It is not a one-install story: you install three packages.
Install
npm install @browsonic/sdk @browsonic/react @browsonic/remix@browsonic/sdk (>=3.12.0), @browsonic/react (^1.2.13) and react (^18 or ^19) are peer dependencies. This package declares no runtime dependencies of its own.
Quickstart — Route ErrorBoundary
// app/routes/some-route.tsx
import { useRouteError } from '@remix-run/react';
import { BrowsonicRouteErrorBoundary } from '@browsonic/remix';
export function ErrorBoundary() {
const error = useRouteError();
// Pass the error through; the boundary captures + renders fallback
return <BrowsonicRouteErrorBoundary error={error} />;
}
export default function Page() {
return <div>...</div>;
}The captured event carries the metadata key remixRouteError plus a remix context bucket with handler: 'routeError'. Both are scoped to that one capture (sdk.withScope), so they do not stick to later events.
Or use the imperative companion when you want a custom fallback UI (it emits the remixRouteError metadata key only — no remix context bucket):
import { useRouteError } from '@remix-run/react';
import { captureRouteError } from '@browsonic/remix';
export function ErrorBoundary() {
const error = useRouteError();
captureRouteError(error);
return <MyCustomErrorScreen error={error} />;
}Quickstart — entry.client.tsx bootstrap
bootstrapBrowsonic writes window.Browsonic.config. It does not load or start the SDK: you also call getBrowsonic() from @browsonic/sdk, and creating that singleton is what makes the SDK pick the config up and initialise.
// app/entry.client.tsx
import { RemixBrowser } from '@remix-run/react';
import { startTransition, StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { getBrowsonic } from '@browsonic/sdk';
import { bootstrapBrowsonic } from '@browsonic/remix';
bootstrapBrowsonic({
apiEndpoint: 'https://your-ingest-endpoint.test/v1/events',
appKey: 'your-app-key',
// Sent as the X-API-KEY header. POST /v1/events is role-gated, so a batch
// with no key is rejected with 403 before it reaches the handler.
apiKey: 'your-publishable-ingest-key',
environment: 'production',
});
// Creates the singleton, which auto-initialises from the config written above.
// The SDK logs a "[Browsonic] Auto-initialising from window.Browsonic.config"
// warning when it does — that is deliberate, so an injected config is auditable.
getBrowsonic();
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
</StrictMode>,
);
});bootstrapBrowsonic reads any existing window.Browsonic.config first (so entry.server.tsx can serialise per-request fields through an injected <script>), merges every defined option on top — undefined is skipped, so apiKey: window.ENV?.BROWSONIC_KEY never clobbers a server-injected value — and returns the SDK singleton if one already exists, otherwise null. SSR-safe: on Node it returns null without touching globals.
One trap: the option type accepts release, but release is not a field the SDK reads — resolveConfig in @browsonic/sdk picks a fixed set of keys and config.release is referenced nowhere in the SDK. Use clientVersion for the build identifier.
Quickstart — Action / loader wrappers
// app/routes/checkout.tsx
import { withBrowsonicRemixAction, withBrowsonicRemixLoader } from '@browsonic/remix';
export const loader = withBrowsonicRemixLoader(async ({ request }) => {
// ... data fetch that may throw
});
export const action = withBrowsonicRemixAction(async ({ request }) => {
const data = await request.formData();
if (!data.get('email')) throw new Error('email required');
return { ok: true };
});Both wrappers stamp the captured event twice. remix.handler: 'action' | 'loader' goes on via sdk.setTag(), which the SDK implements as an alias of addMetadata — so it rides in the event's metadata and is reachable from the dashboard's metadata key=value filter. The same value is mirrored into the remix context bucket, which the dashboard renders as its "Remix context" card. The 0.1-era metadata keys remixAction / remixLoader are still emitted alongside, for back-compat.
Where this actually fires. In a standard Remix app loader and action run on the server, where there is no browser SDK: resolveSdk() returns null, nothing is reported, and the wrapper is a pass-through that re-throws the original error so Remix's response pipeline is preserved. Capture happens only when the wrapped function runs in the browser — i.e. when you wrap clientLoader / clientAction.
Quickstart — Navigation breadcrumbs with route hierarchy
useRemixNavigationBreadcrumbs(useNavigation(), useMatches()) emits a category: 'navigation' breadcrumb each time the Remix navigation state transitions from non-idle → 'idle' (submitting → idle counts, so form-action navigations are included). Repeated idle renders — revalidations, fetcher submits — do not fire it. The first completed transition is suppressed by default; pass { skipInitial: false } to keep it. { category } overrides the breadcrumb category, { sdk } supplies an explicit instance.
// app/root.tsx
import { Outlet, useNavigation, useMatches } from '@remix-run/react';
import { useRemixNavigationBreadcrumbs } from '@browsonic/remix';
export default function App() {
useRemixNavigationBreadcrumbs(useNavigation(), useMatches());
return <Outlet />;
}Breadcrumb data (the breadcrumb message is `${from} → ${to}`):
{
from: '/dashboard/users/42', // see the defect note — currently equals `to`
to: '/dashboard/users/42',
routeId: 'routes/_app.dashboard.users.$userId', // leaf
routeChain: 'routes/_app › routes/_app.dashboard › routes/_app.dashboard.users › routes/_app.dashboard.users.$userId',
}routeId and routeChain are omitted when matches is empty.
Known defect (2026-07-27, not fixed): from is not the origin path. While a navigation is in flight the hook records navigation.location.pathname — the destination — as the "previous" path, so whenever the in-flight navigation carries a location (the normal case) from and to come out equal on the render that completes the transition. The package's own test pins the current behaviour: it asserts the message '/dashboard → /dashboard'. to, routeId and routeChain are unaffected.
Cross-shell URLs that look identical (e.g. /users/42 inside _app vs a public route) become distinguishable in incident triage through routeId / routeChain.
Quickstart — React surface
Re-exported from @browsonic/react, so you don't need a separate import: BrowsonicErrorBoundary (with its BrowsonicErrorBoundaryProps and BrowsonicErrorBoundaryFallback types), useBrowsonic, useUser, useCaptureError, withBrowsonic.
Not re-exported, even though @browsonic/react exports them: the Atlas helpers routeTemplateFromMatches / useTrackPageView / RouteMatchLike, and the WithBrowsonicInjectedProps type. Import @browsonic/react directly for those.
import { Outlet } from '@remix-run/react';
import { BrowsonicErrorBoundary, useUser } from '@browsonic/remix';
export default function Layout() {
useUser({ id: 'u1' });
return (
<BrowsonicErrorBoundary fallback={(err) => <div>{err.message}</div>}>
<Outlet />
</BrowsonicErrorBoundary>
);
}Defensive contract
- The host app must never crash because reporting failed.
- All SDK calls in
try { … } catch {}. - The route boundary still renders fallback when the SDK is unreachable, and when the reporter itself throws.
- The action / loader wrappers still re-throw the original error even when the reporter throws, and pass the handler's resolved value through unchanged. Their declared return type is
Promise<TReturn>, so wrapping a synchronous handler makes it promise-returning.
What this package does NOT do
- Load or initialise the SDK for you.
bootstrapBrowsoniconly writeswindow.Browsonic.config; thegetBrowsonic()call inentry.client.tsxis yours to add. There is no script auto-injection. - Server-runtime capture in Node. The SDK is a browser library; action / loader errors that occur in pure Node have no
windowto write to. The wrapper still re-throws so Remix returns the expected status. Wire your own server logging if needed. - Edge runtime instrumentation. This is queued behind the SDK core gaining a multi-runtime build target, which is an intentional project non-goal — so it does not come due on its own. Reopens only if the SDK core adds one. (Recorded in
ROADMAP.md.) <RemoteCatch>/ pre-Remix-v2CatchBoundaryback-port. Closed 2026-07-27, not parked. Remix v2 replacedCatchBoundarywith the unifiedErrorBoundary+useRouteError, which is the path this adapter ships. If you are still on pre-v2 Remix, upgrading is the supported route.
License
Apache-2.0. See the repo root LICENSE and the package NOTICE.
